"""
Tension Glide String: one guitar string over a 20 x 40 mm aluminum extrusion.
Nothing slides along it. A lead-screw slide pulls on the string's end to change
how hard it's pulled, and the pitch glides with the pull. The Bow Wheel, an
element of its own, bows it with a rosined wheel; a load cell under the tuner
weighs the pull, and a magnetic pickup listens.

Run from the cad/ folder:

    uv run tension-glide-string/model.py

It writes, for the site:
    site/public/files/tension-glide-string/tension-glide-string.step           the whole assembly, for editing
    site/public/files/tension-glide-string/tension-glide-string.glb            the 3D model shown on the page
    site/public/files/tension-glide-string/tension-glide-string-motion.json    how it moves, for the Play button
    site/public/files/tension-glide-string/tension-glide-string-cad.py         this script
    site/src/content/instruments/tension-glide-string/drawings/tension-glide-string-render.svg

It shares Lapless Steel's string, tuner, pickup and extrusion. Lapless Steel
changes a string's length; this changes its pull. Pitch goes with the square
root of the pull, so a fifth takes 2.25 times the pull. The string is tuned to
D3 at about 100 N, Lapless Steel's tuning, and that is the top of the glide.
The motor only ever lets it down from there, to G2 at about 45 N, and pulls it
back. On 520 mm, that whole fifth is about 1.4 mm of travel at the string's end.

The layout, from the tuner end: the load cell stands upright off the end of
the extrusion like a headstock, with the tuner on a shelf on top of it. The
pickup sits on two standoffs near that end, away from both motors. The Bow
Wheel sits on top in front of the string and bows it from the front, its wheel
55 mm short of the saddle, which is a V-groove
bearing so the string rolls instead of dragging as it stretches. Past the
saddle, a bought lead-screw slide sits on top of the extrusion, and the string's
ball end catches in a slotted angle on its carriage.

Coordinates, in millimeters: X runs along the extrusion from the tuner end (0)
to the slide end (L). Y runs across it, front (0) to back (20). Z is up; the
extrusion stands on its 20 mm edge, from 0 to 40. The string runs along X over
the top slot, at Y = 10, 46 mm above the extrusion.

The Bow Wheel is drawn from its own script, cad/bow-wheel/model.py, which this
one imports; its two printed parts are its recipe's, and nothing else here is
printed. Everything else is bought or cut from aluminum angle, 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 importlib.util
import math
import re
import shutil
import sys

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

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

# The Bow Wheel's own script, for its parts and how they move
_spec = importlib.util.spec_from_file_location("bow_wheel_model", HERE.parent / "bow-wheel" / "model.py")
bw = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(bw)

REPO = HERE.parent.parent
OUT = REPO / "site/public/files/tension-glide-string"
DRAWINGS = REPO / "site/src/content/instruments/tension-glide-string/drawings"

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

L = 700.0  # extrusion length; any length works, see the page's Variations
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; the slide's carriage sets it
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

POST_X = -45.0  # the string leaves the side of the tuner post here; the post is the nut
SCALE = 520.0  # tuner post to saddle, as on Lapless Steel
SADDLE_X = POST_X + SCALE

# The pull. A wound .036 on 520 mm pulls about 100 N at D3 and 45 N at G2, a fifth down.
TOP_PULL = 100.0
BOTTOM_PULL = TOP_PULL * (98.00 / 146.83) ** 2
# The string stretches about 1.2 mm between them (its steel core, about 0.13 mm2, over the
# speaking length and the short length behind the saddle). The load cell and the brackets
# give a little more, so the slide travels about 1.4 mm for the whole fifth.
GLIDE = 1.4
LEAD = 2.0  # the slide's screw moves 2 mm a turn: fine enough to hold the pull with the motor off

# The headstock: a 20 kg bar load cell stands upright off the end of the extrusion. Its lower
# (fixed) end bolts to an angle hooked over the end; the tuner stands on a shelf bolted to its
# upper (free) end. The string pulls the free end toward the extrusion, which is what it weighs.
CELL_X = (-24.7, -12.0)  # 12.7 mm thick, 2 mm off the hanging leg on washers
CELL_Z = (0.0, 80.0)
CELL_FIXED = (5.5, 20.5)  # its threaded holes, along X
CELL_FREE = (59.5, 74.5)
SHELF_TOP = 80.0
POST_Y = SY + 3.0  # the post stands upright; the string leaves its front side at Y = 10

# The saddle: a 624VV V-groove bearing, 13 mm across, on an M4 screw through an angle.
BEARING_R, GROOVE_R = 6.5, 5.2
BEARING_Z = SZ - STRING_R - GROOVE_R

# The slide: a bought T8 lead-screw stage, 50 mm of travel, NEMA 17 on the end.
SLIDE_X0 = 500.0
SCREW_Z = 66.0  # the screw's axis, 22 mm above the slide's 4 mm base, so the motor clears the extrusion
NEAR_BLOCK = (SLIDE_X0 + 4, SLIDE_X0 + 18)
FAR_BLOCK = (SLIDE_X0 + 114, SLIDE_X0 + 128)
CARRIAGE_X = SLIDE_X0 + 34  # the carriage's near face, at the top note
CARRIAGE_LEN = 46.0
CARRIAGE_Z = (56.0, 76.0)
ROD_Y = (SY - 20, SY + 20)
SLIDE_MOTOR_X = SLIDE_X0 + 152
ANCHOR_Z = 82.0  # the string where it catches in the anchor's slot
BALL_X = CARRIAGE_X + 5.0

# The Bow Wheel bows the string from the front, its wheel's middle about a tenth of the string
# from the saddle. Its own script draws it on a stand-in with the string at the same place across
# and the same height as here, so it only moves along.
WHEEL_X = SADDLE_X - 55.0
BW_DX = WHEEL_X - bw.WHEEL_X
assert (bw.SY, bw.SZ, bw.TOP) == (SY, SZ, TOP), "the Bow Wheel's stand-in string has moved"

PICKUP_X = POST_X + 130.0  # single coil, lying along the string near the tuner end

M5, M3 = 2.75, 1.7


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


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


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


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


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


def strand(p0, p1, half):
    """A square bar from p0 to p1, half wide. Its sharp edges show in the viewer, where a thin round rod would vanish."""
    d = [b - a for a, b in zip(p0, p1)]
    length = sum(c * c for c in d) ** 0.5
    return Plane(origin=p0, z_dir=d).location * Box(2 * half, 2 * half, length, align=(Align.CENTER, Align.CENTER, Align.MIN))


def string_path():
    """Tuner post, past the wheel (which just touches it), over the saddle, and down to the anchor."""
    return [
        (POST_X, SY, SZ),
        (SADDLE_X, SY, SZ),
        (CARRIAGE_X, SY, ANCHOR_Z),
        (BALL_X - 1.8, SY, ANCHOR_Z),
    ]


def string_z_at(x):
    """The string's height behind the saddle, where it slopes down to the anchor."""
    return SZ + (ANCHOR_Z - SZ) * (x - SADDLE_X) / (CARRIAGE_X - SADDLE_X)


# ---------------------------------------------------------------------------
# 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 headstock():
    """The angle hooked over the end, the load cell standing on it, and the tuner on its shelf."""
    hook = box(-10, 40, 0, 20, TOP, TOP + 4) + box(-10, -6, 0, 20, -6, TOP + 4)  # 50 x 50 x 4 angle, 20 wide
    for x in (5, 30):
        hook -= cyl_z(x, SY, M5, TOP - 1, TOP + 5)
    for z in CELL_FIXED:
        hook -= cyl_x(SY, z, M5, -11, -5)

    cell = box(*CELL_X, SY - 6.35, SY + 6.35, *CELL_Z)
    for z in CELL_FIXED + CELL_FREE:
        cell -= cyl_x(SY, z, 2.5, CELL_X[0] - 1, CELL_X[1] + 1)
    mid = (CELL_Z[0] + CELL_Z[1]) / 2  # the double hole through its middle, where the gauges sit
    for dz in (-6, 6):
        cell -= cyl_y((CELL_X[0] + CELL_X[1]) / 2, mid + dz, 4.5, SY - 7, SY + 7)
    cell -= box(CELL_X[0] + 5.35, CELL_X[1] - 5.35, SY - 7, SY + 7, mid - 6, mid + 6)
    washers = Compound(children=[cyl_x(SY, z, 5, CELL_X[1], -10) for z in CELL_FIXED])

    # The shelf: 30 x 30 x 4 angle, 20 wide. One leg bolts to the cell's free end; the tuner
    # stands in a 10 mm hole in the other, level with the top of the cell.
    shelf = box(CELL_X[0] - 4, CELL_X[0], 0, 20, SHELF_TOP - 30, SHELF_TOP)
    shelf += box(CELL_X[0] - 30, CELL_X[0], 0, 20, SHELF_TOP - 4, SHELF_TOP)
    shelf -= cyl_z(POST_X, POST_Y, 5, SHELF_TOP - 5, SHELF_TOP + 1)
    for z in CELL_FREE:
        shelf -= cyl_x(SY, z, M5, CELL_X[0] - 5, CELL_X[0] + 1)

    # A sealed tuner, standing up: the post above the shelf, the gears under it, the key toward the back.
    tuner = cyl_z(POST_X, POST_Y, 6, SHELF_TOP, SHELF_TOP + 3) + cyl_z(POST_X, POST_Y, 3, SHELF_TOP + 3, SHELF_TOP + 18)
    tuner += box(POST_X - 9, POST_X + 9, POST_Y - 6, POST_Y + 8, SHELF_TOP - 16, SHELF_TOP - 4)
    tuner += cyl_y(POST_X + 5, SHELF_TOP - 10, 2, POST_Y + 8, POST_Y + 18)
    tuner += box(POST_X - 6, POST_X + 16, POST_Y + 18, POST_Y + 26, SHELF_TOP - 14, SHELF_TOP - 6)  # the key
    return [
        ("hook-angle", hook, (0.55, 0.57, 0.6)),
        ("load-cell", cell, (0.78, 0.8, 0.83)),
        ("cell-washers", washers, (0.55, 0.57, 0.6)),
        ("tuner-shelf", shelf, (0.55, 0.57, 0.6)),
        ("tuner", tuner, (0.72, 0.72, 0.72)),
    ]


def saddle():
    """A V-groove bearing on an M4 screw through a 50 x 25 x 3 angle, with felt on the string behind it."""
    x0, x1 = SADDLE_X - 15, SADDLE_X + 15
    angle = box(x0, x1, -5, 20, TOP, TOP + 3) + box(x0, x1, 17, 20, TOP, TOP + 50)
    angle -= cyl_z(SADDLE_X, SY, M5, TOP - 1, TOP + 4)
    angle -= cyl_y(SADDLE_X, BEARING_Z, 2.2, 16, 21)
    bearing = cyl_y(SADDLE_X, BEARING_Z, BEARING_R, SY - 3, SY + 3)
    bearing -= cyl_y(SADDLE_X, BEARING_Z, BEARING_R + 1, SY - 1, SY + 1) - cyl_y(SADDLE_X, BEARING_Z, GROOVE_R, SY - 1, SY + 1)
    bearing -= cyl_y(SADDLE_X, BEARING_Z, 2.0, SY - 4, SY + 4)
    screw = cyl_y(SADDLE_X, BEARING_Z, 2.0, SY - 6, 20) + cyl_y(SADDLE_X, BEARING_Z, 3.5, 20, 23)
    screw += cyl_y(SADDLE_X, BEARING_Z, 3.6, SY + 3, 17) - cyl_y(SADDLE_X, BEARING_Z, 2.1, SY + 3, 17)  # spacer
    screw += box(SADDLE_X - 3.5, SADDLE_X + 3.5, SY - 6, SY - 3.2, BEARING_Z - 3.5, BEARING_Z + 3.5)  # nut
    fx = SADDLE_X + 18
    felt = box(fx - 5, fx + 5, SY - 4, SY + 4, string_z_at(fx) - 4, string_z_at(fx) + 4)
    return [
        ("saddle-angle", angle, (0.55, 0.57, 0.6)),
        ("saddle-bearing", bearing, (0.75, 0.75, 0.78)),
        ("saddle-screw", screw, (0.55, 0.57, 0.6)),
        ("fret-wrap", felt, (0.3, 0.3, 0.32)),
    ]


def slide_parts(dx=0.0):
    """The lead-screw stage. dx moves the carriage from the top note, for the close-up."""
    base = box(SLIDE_X0, SLIDE_MOTOR_X + 40, ROD_Y[0] - 10, ROD_Y[1] + 10, TOP, TOP + 4)
    blocks = box(*NEAR_BLOCK, ROD_Y[0] - 10, ROD_Y[1] + 10, TOP + 4, 80) + box(*FAR_BLOCK, ROD_Y[0] - 10, ROD_Y[1] + 10, TOP + 4, 80)
    rods = Compound(children=[cyl_x(y, SCREW_Z, 4, NEAR_BLOCK[0], FAR_BLOCK[1]) for y in ROD_Y])
    screw = cyl_x(SY, SCREW_Z, 4, NEAR_BLOCK[0] + 4, FAR_BLOCK[1] + 12)

    cx = CARRIAGE_X + dx
    carriage = box(cx, cx + CARRIAGE_LEN, ROD_Y[0] - 10, ROD_Y[1] + 10, *CARRIAGE_Z)
    for y in ROD_Y:
        carriage -= cyl_x(y, SCREW_Z, 4, cx - 1, cx + CARRIAGE_LEN + 1)
    carriage -= cyl_x(SY, SCREW_Z, 4, cx - 1, cx + CARRIAGE_LEN + 1)
    carriage += cyl_x(SY, SCREW_Z, 11, cx - 4, cx) - cyl_x(SY, SCREW_Z, 4, cx - 5, cx + 1)  # the nut's flange

    # The string anchor: 20 x 20 x 3 angle on the carriage's top, a slot sawn down into its
    # upright leg. The string drops in, and its ball end catches behind.
    top = CARRIAGE_Z[1]
    anchor = box(cx, cx + 3, 0, 20, top, top + 20) + box(cx, cx + 20, 0, 20, top, top + 3)
    anchor -= box(cx - 1, cx + 4, SY - 0.75, SY + 0.75, ANCHOR_Z - STRING_R, top + 21)
    for y in (3, 17):
        anchor -= cyl_z(cx + 12, y, 1.7, top - 1, top + 4)
    ball = cyl_y(BALL_X + dx, ANCHOR_Z, 1.8, SY - 1.6, SY + 1.6)

    # The stop collar, on the back rod, against the carriage's far side at the top note.
    cf = CARRIAGE_X + CARRIAGE_LEN + 0.5
    collar = cyl_x(ROD_Y[1], SCREW_Z, 8, cf, cf + 8) - cyl_x(ROD_Y[1], SCREW_Z, 4, cf - 1, cf + 9)
    collar += cyl_z(cf + 4, ROD_Y[1], 1.6, SCREW_Z + 7, SCREW_Z + 10)

    # Between the far block and the motor: two plates, open at the front and back so the coupler shows.
    mx = SLIDE_MOTOR_X
    housing = box(FAR_BLOCK[1], mx, SY - 21, SY + 21, SCREW_Z - 21, SCREW_Z - 18) + box(FAR_BLOCK[1], mx, SY - 21, SY + 21, SCREW_Z + 18, SCREW_Z + 21)
    coupler = cyl_x(SY, SCREW_Z, 9.5, FAR_BLOCK[1] + 6, mx - 2)
    for x in (FAR_BLOCK[1] + 9, mx - 5):
        coupler += cyl_z(x, SY, 1.5, SCREW_Z + 9, SCREW_Z + 11)
    coupler -= cyl_x(SY, SCREW_Z, 10, FAR_BLOCK[1] + 13, FAR_BLOCK[1] + 15) - cyl_x(SY, SCREW_Z, 7.5, FAR_BLOCK[1] + 13, FAR_BLOCK[1] + 15)
    motor = box(mx, mx + 40, SY - 21.15, SY + 21.15, SCREW_Z - 21.15, SCREW_Z + 21.15)
    return [
        ("slide-base", base, (0.55, 0.57, 0.6)),
        ("slide-blocks", blocks, (0.55, 0.57, 0.6)),
        ("slide-rods", rods, (0.75, 0.75, 0.78)),
        ("lead-screw", screw, (0.75, 0.72, 0.6)),
        ("carriage", carriage, (0.55, 0.57, 0.6)),
        ("string-anchor", anchor, (0.78, 0.8, 0.83)),
        ("ball-end", ball, (0.72, 0.62, 0.35)),
        ("stop-collar", collar, (0.2, 0.2, 0.22)),
        ("coupler-housing", housing, (0.55, 0.57, 0.6)),
        ("coupler", coupler, (0.7, 0.7, 0.72)),
        ("nema17-slide", motor, (0.2, 0.2, 0.22)),
    ]


def bow_wheel_parts(arm=0.0, horn=None, electronics=True):
    """The Bow Wheel, as its own script draws it, moved along to here: (label, shape, rgb). arm tips the
    arm away from the string by that many degrees, and horn turns the servo's horn, for the drawings."""
    horn = bw.TOUCH if horn is None else horn
    tipped = lambda shape: bw.tipped(shape, arm)  # noqa: E731
    orange = (0.86, 0.42, 0.28)
    unit = [
        ("printed:bow-wheel-base", bw.base(), orange),
        ("printed:bow-wheel-arm", tipped(bw.arm()), orange),
        ("bought:bow-wheel-hinge", bw.hinge_screw(), (0.6, 0.62, 0.65)),
        ("bought:bow-wheel-screws", bw.base_screws(), (0.6, 0.62, 0.65)),
        ("bought:bow-wheel-servo", bw.servo(), (0.16, 0.2, 0.42)),
        ("bought:bow-wheel-horn", bw.servo_horn(horn), (0.92, 0.92, 0.9)),
        ("bought:bow-wheel-blade", tipped(bw.blade()), (0.75, 0.77, 0.8)),
        ("bought:bow-wheel-clamp", tipped(bw.blade_clamp()), (0.6, 0.62, 0.65)),
        ("bought:bow-wheel-motor", tipped(bw.motor()), (0.2, 0.2, 0.22)),
    ]
    flange, wheel = (shape for _, shape, _ in bw.wheel_parts())
    unit += [("bought:bow-wheel-flange", tipped(flange), (0.7, 0.7, 0.72)), ("bought:bow-wheel-wheel", tipped(wheel), (0.72, 0.55, 0.36))]
    # Its own electronics, on the back face between the Tension Glide's and the slide: the box with
    # its stop button on top, and its battery adapter beside it.
    if electronics:
        unit += [
            ("bought:bow-wheel-box", box(380 - BW_DX, 454 - BW_DX, 20, 56, 2, 38) + cyl_y(392 - BW_DX, 30, 4, 56, 61), (0.3, 0.3, 0.32)),
            ("bought:bow-wheel-stop-button", box(400 - BW_DX, 434 - BW_DX, 22, 54, 38, 64) + cyl_z(417 - BW_DX, 38, 13, 64, 75), (0.85, 0.7, 0.2)),
            ("bought:bow-wheel-battery-adapter", box(300 - BW_DX, 356 - BW_DX, 20, 38, 4, 36), (0.3, 0.3, 0.32)),
            ("bought:bow-wheel-battery", box(292 - BW_DX, 364 - BW_DX, 38, 108, 0, 78), (0.6, 0.2, 0.15)),
        ]
    return [(label, Pos(BW_DX, 0, 0) * shape, rgb) for label, shape, rgb in unit]


def pickup_parts():
    """A single-coil pickup lying along the string, on two M3 standoffs in the top slot, a pole row
    3 mm under the string."""
    top = SZ - STRING_R - 3
    body_z = (top - 16, top - 1)
    pickup = box(PICKUP_X - 35, PICKUP_X + 35, SY - 8.5, SY + 8.5, *body_z)
    for i in range(6):
        pickup += cyl_z(PICKUP_X - 26.25 + 10.5 * i, SY, 2.5, top - 1, top)
    for sign in (-1, 1):
        ear = box(*sorted((PICKUP_X + sign * 35, PICKUP_X + sign * 42)), SY - 4, SY + 4, body_z[0], body_z[0] + 1.5)
        pickup += ear - cyl_z(PICKUP_X + sign * 38.5, SY, M3, body_z[0] - 1, body_z[0] + 3)
    standoffs = Compound(children=[cyl_z(PICKUP_X + s * 38.5, SY, 2.75, TOP, body_z[0]) for s in (-1, 1)])
    return [("pickup", pickup, (0.12, 0.12, 0.12)), ("pickup-standoffs", standoffs, (0.55, 0.57, 0.6))]


def bought_parts():
    parts = [("extrusion-2040", extrusion(), (0.78, 0.8, 0.83))]
    parts += headstock()
    parts += pickup_parts()
    parts += saddle()
    parts += slide_parts()

    # On the back face, below the slide's overhang: the electronics in a small box, the stop
    # button in its own housing near the tuner end, and the battery adapter at the slide end.
    parts += [
        ("electronics-box", box(160, 280, 22, 62, -15, 22) + cyl_x(42, 2, 5, 280, 286), (0.3, 0.3, 0.32)),
        ("stop-button", box(30, 64, 21, 71, -15, 35) + cyl_z(47, 46, 14, 35, 47), (0.85, 0.7, 0.2)),
        ("battery-adapter", box(550, 630, 20, 40, 1, 31), (0.3, 0.3, 0.32)),
        ("battery", box(540, 640, 41, 111, -18, 38), (0.6, 0.2, 0.15)),
    ]

    # Feet: two short pieces of 20 x 20 extrusion across the underside, reaching forward under the Bow Wheel.
    parts.append(("feet-2020", box(40, 60, -60, 80, -20, 0) + box(620, 640, -60, 80, -20, 0), (0.78, 0.8, 0.83)))

    path = string_path()
    wraps = cyl_z(POST_X, POST_Y, 3.9, SZ - 2.2, SZ + 0.6) - cyl_z(POST_X, POST_Y, 3, SZ - 3, SZ + 1)
    parts.append(("string", Compound(children=[strand(a, b, DRAWN_R) for a, b in zip(path, path[1:])] + [wraps]), (0.85, 0.7, 0.3)))
    return parts


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


CARRIAGE = ["bought:carriage", "bought:string-anchor", "bought:ball-end"]
SCREW = ["bought:lead-screw", "bought:coupler"]
WHEEL = ["bought:bow-wheel-wheel", "bought:bow-wheel-flange"]
ARM = ["printed:bow-wheel-arm", "bought:bow-wheel-motor", "bought:bow-wheel-blade", "bought:bow-wheel-clamp"] + WHEEL


def motion():
    """The Bow Wheel spins up and tips onto the string; the screw lets the string down a fifth and
    pulls it back, with vibrato at the top; the Bow Wheel lifts, its wheel stops, and it comes back
    still to damp the string."""

    def screw_turn(dx):
        return round(dx / LEAD * 360.0, 2)

    # The wheel, drawn turning slower than it runs: up to speed while it's clear, steady while it
    # bows, and slowing to a stop once it has lifted. The rim runs down past the string.
    def rpm(t):
        return 60 * bw.smooth(t, 0.1, 0.7) * (1 - bw.smooth(t, 7.9, 8.5))

    dt = 0.025
    times = [round(i * dt, 3) for i in range(int(10 / 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)))

    lift = bw.LIFT
    arm_keys = [(0, lift), (0.8, lift), (0.95, 0), (7.6, 0), (7.72, lift), (8.75, lift), (8.9, 0), (9.4, 0), (9.55, lift), (10, lift)]
    rel = lambda a: bw.TOUCH - a  # noqa: E731
    middle = bw.horn_for(2.0)
    horn_keys = [(0, rel(bw.HORN_LIFT)), (0.8, rel(bw.HORN_LIFT)), (0.95, rel(middle)), (7.6, rel(middle)), (7.72, rel(bw.HORN_LIFT)),
                 (8.75, rel(bw.HORN_LIFT)), (8.9, rel(bw.HORN_DAMP)), (9.4, rel(bw.HORN_DAMP)), (9.55, rel(bw.HORN_LIFT)), (10, rel(bw.HORN_LIFT))]

    # The carriage: down a fifth, back up, then vibrato at the top, about a quarter of a semitone each way.
    vib = 0.073
    carriage = [(0, 0), (1.6, 0), (3.4, -GLIDE), (4.2, -GLIDE), (5.6, 0), (5.8, 0)]
    t, sign = 5.9, 1
    while t < 7.35:
        carriage.append((round(t, 2), sign * vib))
        t += 0.1
        sign = -sign
    carriage += [(7.4, 0), (10, 0)]

    write_motion(
        OUT / "tension-glide-string-motion.json",
        duration=10,
        tracks=[
            turn(WHEEL, (-1, 0, 0), (WHEEL_X, bw.WY, SZ), spin, ease="linear"),  # spun about its own axis first
            turn(ARM, (1, 0, 0), (WHEEL_X, bw.PIN_Y, bw.PIN_Z), arm_keys),  # then tipped with the arm
            turn(["bought:bow-wheel-horn"], (1, 0, 0), (0, bw.SERVO_Y, bw.SERVO_Z), horn_keys),
            slide(CARRIAGE, (1, 0, 0), carriage),
            turn(SCREW, (1, 0, 0), (FAR_BLOCK[1], SY, SCREW_Z), [(t, screw_turn(dx)) for t, dx in carriage]),
        ],
        captions=[
            (0, "The Bow Wheel spins its wheel up while it's clear of the string. It's drawn turning slower than it runs."),
            (0.8, "Its servo tips the wheel onto the string, and the rosined rim bows it from the side, like a hurdy-gurdy."),
            (1.6, f"The screw turns back less than a turn. The string's end creeps {GLIDE:.1f} mm toward the saddle, its pull drops from {TOP_PULL:.0f} N to {BOTTOM_PULL:.0f} N, and the note glides down a fifth."),
            (4.2, "Turning it forward pulls the string tight again, and the note glides back up."),
            (5.8, "Small, quick turns back and forth make vibrato."),
            (7.6, "The Bow Wheel lifts clear and the string rings on, while its wheel slows to a stop."),
            (8.75, "The still wheel comes back and rests on the string to damp it, then lifts away."),
        ],
    )


def check_room(assembly):
    """Warn if the Bow Wheel, moved here, runs into any of the Tension Glide's own parts. The string
    is left out, since the rim touches it."""
    unit = [p for p in assembly if "bow-wheel" in p.label]
    rest = [p for p in assembly if "bow-wheel" not in p.label and p.label != "bought:string"]
    for a in unit:
        for b in rest:
            ba, bb = a.bounding_box(), b.bounding_box()
            if ba.max.X < bb.min.X or bb.max.X < ba.min.X or ba.max.Y < bb.min.Y or bb.max.Y < ba.min.Y or ba.max.Z < bb.min.Z or bb.max.Z < ba.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.mkdir(parents=True, exist_ok=True)
    DRAWINGS.mkdir(parents=True, exist_ok=True)

    print(f"pull: top {TOP_PULL:.0f} N, bottom {BOTTOM_PULL:.1f} N; glide {GLIDE} mm = {GLIDE / LEAD:.2f} screw turns")
    print(f"the Bow Wheel's wheel {SADDLE_X - WHEEL_X:.0f} mm short of the saddle; its base from X {bw.BASE_X[0] + BW_DX:.0f} to {bw.BASE_X[1] + BW_DX:.0f}")
    assembly = []
    for name, shape, rgb in bought_parts():
        shape.label = f"bought:{name}"
        shape.color = Color(*rgb)
        assembly.append(shape)
    for label, shape, rgb in bow_wheel_parts():
        shape.label = label
        shape.color = Color(*rgb)
        assembly.append(shape)
    check_room(assembly)

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

    render(DRAWINGS / "tension-glide-string-render.svg")
    print("written to", OUT)


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


def headstock_closeup():
    parts = [shape for _, shape, _ in headstock()] + [extrusion(-10, 60)]
    parts.append(strand((POST_X, SY, SZ), (60, SY, SZ), STRING_R))
    return Compound(children=parts)


def pull_end_closeup():
    parts = [shape for _, shape, _ in saddle() + slide_parts()] + [extrusion(SADDLE_X - 30, L)]
    path = string_path()[3:]
    parts += [strand(a, b, STRING_R) for a, b in zip(path, path[1:])]
    parts.append(strand((SADDLE_X - 40, SY, SZ), (SADDLE_X, SY, SZ), STRING_R))
    return Compound(children=parts)


def wheel_closeup():
    parts = [shape for _, shape, _ in bow_wheel_parts(horn=bw.horn_for(2.0), electronics=False)]
    parts += [extrusion(WHEEL_X - 130, WHEEL_X + 40), strand((WHEEL_X - 130, SY, SZ), (WHEEL_X + 40, SY, 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):
    sheet(
        path,
        "Tension Glide String: the three working ends",
        "Drawn from the CAD model.",
        [
            (headstock_closeup(), (-200, -170, 170), (-15, 10, 50), "THE LOAD CELL STANDS OFF THE END LIKE A HEADSTOCK, AND WEIGHS THE STRING THROUGH THE TUNER"),
            (wheel_closeup(), (WHEEL_X + 260, -330, 250), (bw.MX + BW_DX + 5, bw.WY - 10, SZ - 15), "THE BOW WHEEL BOWS THE STRING FROM THE FRONT, TIPPED ON BY ITS SERVO"),
            (pull_end_closeup(), (SADDLE_X - 60, -260, 200), (SADDLE_X + 110, SY, 65), "THE STRING ROLLS OVER A BEARING AND CATCHES ON THE SLIDE, WHICH PULLS IT"),
        ],
    )


if __name__ == "__main__":
    main()
