"""
Lapless Steel: one guitar string over a 20 x 40 mm aluminum extrusion. A steel
rod rides under the string on a belt-driven V-slot plate, and where it sits
sets the note, so every change of note is a glide. A servo plucks, and a
magnetic pickup listens.

Run from the cad/ folder:

    uv run lapless-steel/model.py

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

The layout is the owner's sketch: the extrusion stands on its 20 mm edge, a
V-wheel plate rides on top, and the belt runs in a loop along the front, from
the stepper at the tuner end, far from the pickup, to a 3D printer's belt
tensioner at the bridge end. One printed piece on the plate snaps the slide rod
in under the string and grips the belt.

Coordinates, in millimeters: X runs along the extrusion from the tuner end (0)
to the bridge end (L). 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, about 23 mm above the extrusion.

Only the rod holder is 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 re
import shutil
import sys

from build123d import (
    Align,
    Box,
    Color,
    Compound,
    Cylinder,
    ExportSVG,
    Plane,
    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/lapless-steel"
DRAWINGS = REPO / "site/src/content/instruments/lapless-steel/drawings"

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

L = 600.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 = 63.0  # the string's height where it leaves the tuner post and crosses the saddle
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 = 30.0  # the string leaves the top of the tuner post here; the post is the nut
SCALE = 520.0  # tuner post to bridge saddle: a .036 tuned to D3 pulls about 100 N
BRIDGE_X = POST_X + SCALE  # the saddle's crest

# A one-piece single-string bridge, the through-body kind: the string comes up
# through it from underneath, and its ball end sits below it, on a washer.
BRIDGE_LEN, BRIDGE_W, BRIDGE_SCREWS = 49.0, 10.45, 36.5
BRIDGE_C = BRIDGE_X + 12.0  # the bridge's middle; the saddle's crest sits toward the tuner end
BRIDGE_HOLE_X = BRIDGE_X + 25.0  # where the string comes up through it
BRIDGE_BASE = SZ - STRING_R - 11.0  # the bridge's underside; its saddle stands about 11 mm above that

ROD_R = 3.0  # the slide: a 6 x 18 mm steel dowel pin, across the string
ROD_LIFT = 1.5  # how far the rod pushes the string up from the straight line; the string's pull holds it down
ROD_Z = SZ - STRING_R + ROD_LIFT - ROD_R  # the rod's center
ROD_Y = (SY - 9, SY + 9)
SNAP = 5.4  # the cradles' mouth, narrower than the rod, so it clicks in

CX = 250.0  # carriage position shown in the model; it travels from about 100 to 435
HOME_X = 60.0  # the home switch's roller, which the plate's back edge presses with the rod at about 95

# The carriage: a 65 x 65 x 3 mm V-slot gantry plate on top, with solid
# V-wheels in the upper slots of the two 40 mm faces.
PLATE_Y = (SY - 32.5, SY + 32.5)
PLATE_Z = TOP + 2.0  # the plate's underside
PLATE_TOP = PLATE_Z + 3.0
WHEEL_Z = 30.0
WHEEL_Y = (-9.8, 29.8)  # wheel centers, 24 mm across
WHEEL_DX = 20.0

# The belt: a 6 mm GT2 belt on edge, in a loop in front of the extrusion. Its
# back run ends in the printed piece's grip. The motor stands on a steel L-bracket
# at the tuner end, shaft up, as far from the pickup as it can be. At the bridge
# end, a matching L-bracket is the shelf for a bought printer belt tensioner, whose
# idler lies flat beside the motor's pulley's line and is pulled out by a knob.
PITCH_R = 6.37
SHELF_Z = (TOP - 7.0, TOP - 4.0)  # both brackets' shelves, in front of the extrusion
BELT_Z = SHELF_Z[1] + 10.0  # the tensioner's idler sits about 10 mm above the shelf; the motor's pulley is set to match
BELT_BACK_Y = -33.0
PULLEY_Y = BELT_BACK_Y - PITCH_R
BELT_FRONT_Y = PULLEY_Y - PITCH_R
MOTOR_X = 22.0
TENSIONER_BRACKET_X = L - 25.0
IDLER_X = L + 22.0  # the tensioner holds its idler just past the extrusion's end

GRIP_HALF = 14.0  # the printed piece is 28 mm long along the extrusion
GRIP_Y = (BELT_BACK_Y - 4.0, BELT_BACK_Y + 4.5)  # the jaw, around the belt's back run
GRIP_Z = (BELT_Z - 9.0, BELT_Z + 9.0)

PICKUP_X = 521.0  # single-coil pickup, under the string between the slide's travel and the bridge
POLE_PITCH = 10.5

PICK_X = 479.0  # where the pick crosses the string
SERVO_Y, SERVO_Z = SY - 21.0, SZ  # MG90S shaft, along X, in front of the string
SERVO_REST = -25.0  # degrees: the arm rests below the string and plucks by swinging up past it

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(cx):
    """Tuner post, over the rod, to the saddle, and down through the bridge to the ball end."""
    over_rod = ROD_Z + ROD_R + STRING_R
    return [(POST_X, SY, SZ), (cx, SY, over_rod), (BRIDGE_X, SY, SZ), (BRIDGE_HOLE_X, SY, BRIDGE_BASE + 3.5), (BRIDGE_HOLE_X, SY, BRIDGE_BASE - 3)]


# ---------------------------------------------------------------------------
# The printed part, in assembly position
# ---------------------------------------------------------------------------


def rod_holder(cx):
    """Sits on the gantry plate. Its two cheeks hold the slide rod across the string,
    just under it, in cradles it clicks into. An arm runs forward over the plate's
    front edge to a jaw that grips both ends of the belt. The whole piece is one
    outline, 28 mm long, so it prints flat on its side."""
    x0, x1 = cx - GRIP_HALF, cx + GRIP_HALF
    p = box(x0, x1, ROD_Y[0] - 2, ROD_Y[1] + 2, PLATE_TOP, PLATE_TOP + 4)  # base, on the plate
    for y0, y1 in ((ROD_Y[0] - 2, ROD_Y[0] + 4), (ROD_Y[1] - 4, ROD_Y[1] + 2)):  # cheeks either side of the string
        p += box(x0, x1, y0, y1, PLATE_TOP + 4, ROD_Z + 3.6)
    p += box(x0, x1, GRIP_Y[1], ROD_Y[0] - 2, PLATE_TOP, PLATE_TOP + 4)  # arm, forward over the plate
    p += box(x0, x1, *GRIP_Y, *GRIP_Z)  # jaw, around the belt
    # The cradles: a hole a little bigger than the rod, with a mouth a little narrower, so it clicks in.
    # The outer 1 mm of each cheek is left whole, so the rod can't slide out sideways.
    p -= cyl_y(cx, ROD_Z, ROD_R + 0.05, *ROD_Y)
    p -= box(cx - SNAP / 2, cx + SNAP / 2, *ROD_Y, ROD_Z, ROD_Z + 10)
    # M5 screws down into two of the plate's holes, between the cheeks.
    for dx in (-9, 9):
        p -= cyl_z(cx + dx, SY, M5, PLATE_TOP - 1, PLATE_TOP + 5)
    # The belt's two ends slide into a slot from each end of the jaw, 10 mm deep, and two M3 screws
    # each side, above and below the belt, pinch it.
    for sign in (-1, 1):
        lo, hi = sorted((cx + sign * GRIP_HALF, cx + sign * 4))
        p -= box(lo - (1 if sign < 0 else 0), hi + (1 if sign > 0 else 0), BELT_BACK_Y - 1, BELT_BACK_Y + 1, BELT_Z - 3.4, BELT_Z + 3.4)
        for z in (BELT_Z - 6, BELT_Z + 6):
            p -= cyl_y(cx + sign * 9, z, M3, GRIP_Y[0] - 1, GRIP_Y[1] + 1)
    return p


# (name, builder, rotation that lays it flat for printing, what it is)
PRINTED = [
    ("rod-holder", lambda: rod_holder(CX), Rot(0, 90, 0), "Rod holder: snaps the slide rod in under the string, and grips the belt"),
]


# ---------------------------------------------------------------------------
# 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 carriage(cx):
    """The gantry plate and its four wheels."""
    plate = box(cx - 32.5, cx + 32.5, *PLATE_Y, PLATE_Z, PLATE_TOP)
    wheels, spacers = [], []
    for dx in (-WHEEL_DX, WHEEL_DX):
        for y in WHEEL_Y:
            wheels.append(cyl_z(cx + dx, y, 9.6, WHEEL_Z - 5, WHEEL_Z + 5) + cyl_z(cx + dx, y, 12, WHEEL_Z - 1.5, WHEEL_Z + 1.5))
            spacers.append(cyl_z(cx + dx, y, 4, WHEEL_Z + 5, PLATE_Z))
    return {
        "gantry-plate": plate,
        "v-wheels": Compound(children=wheels),
        "wheel-spacers": Compound(children=spacers),
        "slide-rod": cyl_y(cx, ROD_Z, ROD_R, *ROD_Y),
    }


def pulley(x, toothed_r):
    p = cyl_z(x, PULLEY_Y, toothed_r, BELT_Z - 3, BELT_Z + 3)
    p += cyl_z(x, PULLEY_Y, 8, BELT_Z - 4, BELT_Z - 3) + cyl_z(x, PULLEY_Y, 8, BELT_Z + 3, BELT_Z + 4)
    return p


def belt():
    """The loop: two straight runs, and a half turn around the idler and the motor pulley.
    The back run is drawn whole; its two ends meet inside the printed piece's grip."""
    lo_x, hi_x = sorted((MOTOR_X, IDLER_X))
    b = box(lo_x, hi_x, BELT_BACK_Y - 0.7, BELT_BACK_Y + 0.7, BELT_Z - 3, BELT_Z + 3)
    b += box(lo_x, hi_x, BELT_FRONT_Y - 0.7, BELT_FRONT_Y + 0.7, BELT_Z - 3, BELT_Z + 3)
    for x, outside in ((lo_x, -1), (hi_x, 1)):
        ring = cyl_z(x, PULLEY_Y, PITCH_R + 0.7, BELT_Z - 3, BELT_Z + 3) - cyl_z(x, PULLEY_Y, PITCH_R - 0.7, BELT_Z - 3, BELT_Z + 3)
        lo, hi = sorted((x, x - 10 * outside))  # keep the half on the outside
        b += ring - box(lo, hi, PULLEY_Y - 10, PULLEY_Y + 10, BELT_Z - 4, BELT_Z + 4)
    return b


def l_bracket(x, half=22.0):
    """A bought NEMA 17 steel L-bracket: a shelf in front of the extrusion, and a leg bolted to its front face."""
    shelf = box(x - half, x + half, PULLEY_Y - 22.5, -3, *SHELF_Z)
    return shelf + box(x - half, x + half, -3, 0, 0, SHELF_Z[1])


def tensioner():
    """A bought 3D printer belt tensioner (the kind made for an Ender 3's X axis): a flat bar bolted
    to the bracket's shelf, and a frame past the end holding a toothed idler that lies flat. The knob
    draws the idler outward to tighten the belt. Drawn roughly; check yours."""
    y0, y1 = PULLEY_Y - 10, PULLEY_Y + 10
    bar = box(L - 48, IDLER_X + 20, y0, y1, SHELF_Z[1], SHELF_Z[1] + 4)
    frame = box(IDLER_X - 18, IDLER_X + 20, y0, y1, BELT_Z + 6, BELT_Z + 9) + box(IDLER_X + 17, IDLER_X + 20, y0, y1, SHELF_Z[1] + 4, BELT_Z + 6)
    knob = cyl_x(PULLEY_Y, BELT_Z, 7, IDLER_X + 20, IDLER_X + 30) + cyl_x(PULLEY_Y, BELT_Z, 2, IDLER_X + 8, IDLER_X + 20)
    idler = pulley(IDLER_X, PITCH_R) + cyl_z(IDLER_X, PULLEY_Y, 2.5, SHELF_Z[1] + 4, BELT_Z + 9)
    return bar + frame + knob, idler


def servo_parts():
    y, z = SERVO_Y, SERVO_Z
    top = PICK_X + 6.5  # the servo's case, shaft side; its tabs sit 6.8 mm down from here
    case = box(top, top + 22.5, y - 6.1, y + 6.1, z - 16.9, z + 5.9)
    case += box(top + 4.3, top + 6.8, y - 6.1, y + 6.1, z - 5.5 - 16.25, z - 5.5 + 16.25)  # tabs
    case += cyl_x(y, z, 2.4, PICK_X + 1.5, top)
    bracket = box(top + 6.8, top + 9.8, y - 9, -2, TOP - 2, z + 14) - box(top + 6, top + 11, y - 6.4, y + 6.4, z - 17.2, z + 6.2)
    bracket += box(top + 6.8, top + 22, -3, 0, 20, z + 14)  # leg, bolted to the front face's upper slot
    # The horn's arm points at the string; a mini binder clip on its tip holds a pick across the sweep.
    reach = SY - y  # shaft to string
    horn = cyl_x(y, z, 3.5, PICK_X - 0.5, PICK_X + 1.5) + box(PICK_X - 0.5, PICK_X + 1.5, y, y + reach - 4, z - 2.5, z + 2.5)
    clip = box(PICK_X - 3.5, PICK_X + 1.5, y + reach - 7, y + reach - 3, z - 2.5, z + 2.5)
    pick = box(PICK_X - 5, PICK_X + 1, y + reach - 4, y + reach + 2, z - 0.3, z + 0.3)
    rest = lambda s: Pos(0, y, z) * Rot(SERVO_REST, 0, 0) * Pos(0, -y, -z) * s  # noqa: E731
    return case, bracket, rest(horn + clip), rest(pick)


def bought_parts(cx=CX):
    parts = [("extrusion-2040", extrusion(), (0.78, 0.8, 0.83))]
    for name, shape in carriage(cx).items():
        parts.append((name, shape, (0.55, 0.57, 0.6) if name != "slide-rod" else (0.75, 0.75, 0.78)))

    # The belt drive, in front: the motor at the tuner end, the tensioner at the bridge end.
    frame, idler = tensioner()
    parts += [
        ("nema17", box(MOTOR_X - 21.15, MOTOR_X + 21.15, PULLEY_Y - 21.15, PULLEY_Y + 21.15, SHELF_Z[0] - 40, SHELF_Z[0]), (0.2, 0.2, 0.22)),
        ("motor-bracket", l_bracket(MOTOR_X), (0.55, 0.57, 0.6)),
        ("pulley", pulley(MOTOR_X, 6.1) + cyl_z(MOTOR_X, PULLEY_Y, 2.5, SHELF_Z[0], BELT_Z + 6), (0.7, 0.7, 0.72)),
        ("tensioner-bracket", l_bracket(TENSIONER_BRACKET_X), (0.55, 0.57, 0.6)),
        ("belt-tensioner", frame, (0.16, 0.16, 0.18)),
        ("tensioner-idler", idler, (0.7, 0.7, 0.72)),
        ("belt", belt(), (0.15, 0.15, 0.15)),
    ]

    # The tuner, in a drilled aluminum angle on the top slot. The string leaves the top of its post.
    post_z = SZ - 3 - STRING_R
    angle = box(POST_X - 20, POST_X + 20, 0, 20, TOP, TOP + 3) + box(POST_X - 20, POST_X + 20, 17, 20, TOP, TOP + 40)
    angle -= cyl_y(POST_X, post_z, 5, 16, 21)
    tuner = cyl_y(POST_X, post_z, 3, 1, 17) + cyl_y(POST_X, post_z, 6, 14, 17)
    tuner += box(POST_X - 9, POST_X + 13, 20, 32, post_z - 8, post_z + 8)
    tuner += cyl_x(26, post_z, 2, POST_X + 13, POST_X + 23) + box(POST_X + 23, POST_X + 31, 20, 32, post_z - 11, post_z + 11)  # 22 mm key
    parts += [
        ("tuner-angle", angle, (0.55, 0.57, 0.6)),
        ("tuner", tuner, (0.72, 0.72, 0.72)),
        ("felt-mute", box(POST_X + 13, POST_X + 19, SY - 4, SY + 4, TOP + 3, SZ - STRING_R), (0.3, 0.3, 0.32)),
    ]

    # The one-piece single-string bridge on two standoffs. Its saddle sets the string's height.
    bridge = box(BRIDGE_C - BRIDGE_LEN / 2, BRIDGE_C + BRIDGE_LEN / 2, SY - BRIDGE_W / 2, SY + BRIDGE_W / 2, BRIDGE_BASE, BRIDGE_BASE + 3.5)
    bridge += box(BRIDGE_X - 2, BRIDGE_X + 2, SY - 4, SY + 4, BRIDGE_BASE + 3.5, SZ - STRING_R)  # the saddle's crest
    bridge += box(BRIDGE_X + 2, BRIDGE_X + 18.5, SY - 4, SY + 4, BRIDGE_BASE + 3.5, BRIDGE_BASE + 4.5)  # the rest of the saddle
    bridge -= cyl_z(BRIDGE_HOLE_X, SY, 1.8, BRIDGE_BASE - 1, BRIDGE_BASE + 5)
    standoffs = Compound(children=[cyl_z(BRIDGE_C + d, SY, 2.75, TOP, BRIDGE_BASE) for d in (-BRIDGE_SCREWS / 2, BRIDGE_SCREWS / 2)])
    washer = cyl_z(BRIDGE_HOLE_X, SY, 2.5, BRIDGE_BASE - 0.6, BRIDGE_BASE) + cyl_z(BRIDGE_HOLE_X, SY, 2.1, BRIDGE_BASE - 3.6, BRIDGE_BASE - 0.6)
    parts += [
        ("bridge", bridge, (0.16, 0.16, 0.18)),
        ("bridge-standoffs", standoffs, (0.55, 0.57, 0.6)),
        ("ball-end-and-washer", washer, (0.72, 0.62, 0.35)),
    ]

    # Single-coil pickup on foam pads on top of the extrusion, one pole under the string.
    py = SY + 0.5 * POLE_PITCH  # the string over the third pole
    pickup_top = SZ - STRING_R - 3
    pickup = box(PICKUP_X - 9, PICKUP_X + 9, py - 35, py + 35, TOP + 2, pickup_top - 1)
    for i in range(6):
        pickup += cyl_z(PICKUP_X, py - 2.5 * POLE_PITCH + i * POLE_PITCH, 2.5, pickup_top - 1, pickup_top)
    parts += [
        ("pickup", pickup, (0.12, 0.12, 0.12)),
        ("pickup-foam", box(PICKUP_X - 8, PICKUP_X + 8, 1, 19, TOP, TOP + 2), (0.3, 0.3, 0.32)),
    ]

    case, bracket, horn, pick = servo_parts()
    parts += [
        ("servo-mg90s", case, (0.25, 0.3, 0.45)),
        ("servo-bracket", bracket, (0.55, 0.57, 0.6)),
        ("servo-horn", horn, (0.9, 0.9, 0.88)),
        ("pick", pick, (0.9, 0.85, 0.75)),
    ]

    # The home switch: a micro switch on a V-slot mount on the back face at the tuner end,
    # where the plate's back edge presses its roller at the end of the plate's travel.
    switch = box(HOME_X - 22, HOME_X - 2, 23, 29.4, 30, 40) + box(HOME_X - 12, HOME_X - 2, 24.2, 28.2, 40, 41)
    switch += cyl_y(HOME_X, TOP + 2.5, 2.5, 24.2, 28.2)
    parts += [
        ("home-switch", switch, (0.15, 0.15, 0.15)),
        ("switch-mount", box(HOME_X - 20, HOME_X - 2, 20, 23, 5, 40), (0.55, 0.57, 0.6)),
    ]

    # Two units on the back face, both past the plate's travel. At the tuner end, beside the motor and
    # as far as they can be from the pickup: the electronics in one box, with the stop button in its lid.
    # At the bridge end: the battery on its adapter, with the fuse beside it. The pickup's jack sits on
    # a small bracket near the pickup, so its cable stays short and away from the motor's.
    box_x, box_y, box_z = (-52.0, 36.0), (20.0, 80.0), (-18.0, 42.0)
    button_x, button_y = (box_x[0] + box_x[1]) / 2, (box_y[0] + box_y[1]) / 2
    parts += [
        ("electronics-box", box(*box_x, *box_y, *box_z), (0.3, 0.3, 0.32)),
        ("stop-button", cyl_z(button_x, button_y, 15, box_z[1], box_z[1] + 4) + cyl_z(button_x, button_y, 12, box_z[1] + 4, box_z[1] + 16), (0.85, 0.2, 0.15)),
        ("battery-adapter", box(480, 560, 20, 40, 1, 31), (0.3, 0.3, 0.32)),
        ("battery", box(470, 570, 41, 111, -18, 40), (0.6, 0.2, 0.15)),
        ("output-jack", box(562, 577, 20, 23, 0, 18) + cyl_y(569.5, 9, 5, 23, 33), (0.55, 0.57, 0.6)),
    ]

    # Feet: two short pieces of 20 x 20 extrusion across the underside, clear of the motor.
    parts.append(("feet-2020", box(50, 70, -70, 40, -20, 0) + box(520, 540, -70, 40, -20, 0), (0.78, 0.8, 0.83)))

    path = string_path(cx)
    parts.append(("string", Compound(children=[strand(a, b, DRAWN_R) for a, b in zip(path, path[1:])]), (0.85, 0.7, 0.3)))
    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


CARRIAGE = ["bought:gantry-plate", "bought:v-wheels", "bought:wheel-spacers", "bought:slide-rod", "printed:rod-holder"]
PLUCKER = ["bought:servo-horn", "bought:pick"]


def motion():
    """Pluck, glide up, pluck, glide down: the carriage moves the rod under the ringing string."""
    swing = -2 * SERVO_REST  # from resting below the string to just above it
    write_motion(
        OUT / "lapless-steel-motion.json",
        duration=10,
        tracks=[
            slide(CARRIAGE, (1, 0, 0), [(0, 0), (1.2, 0), (3.2, 150), (4.4, 150), (5.6, -100), (6.8, -100), (8.2, 60), (9.2, 60), (10, 0)]),
            turn(
                PLUCKER,
                (1, 0, 0),
                (PICK_X, SERVO_Y, SERVO_Z),
                [(0, 0), (0.4, 0), (0.55, swing), (3.6, swing), (3.75, 0), (6.0, 0), (6.15, swing), (8.6, swing), (8.75, 0)],
            ),
        ],
        captions=[
            (0, "The servo swings a pick up through the string."),
            (1.2, "The plate glides toward the bridge. The rod under the string shortens it, so the note slides up."),
            (3.6, "A pluck on the way back down."),
            (4.4, "Gliding back lengthens the string, and the note slides down."),
            (6.0, "Every change of note is a glide, like a lap steel guitar with no one playing it."),
        ],
    )


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

    assembly = []
    print(f"{'part':16} {'volume cm3':>10}  {'print size (mm)':>18}  valid")
    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:16} {part.volume / 1000:10.1f}  {size.X:5.0f} x {size.Y:4.0f} x {size.Z:4.0f}  {part.is_valid}")
        export_stl(flat, OUT / "stl" / f"{name}.stl", tolerance=0.02, angular_tolerance=0.2)

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

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

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


def carriage_closeup():
    """The carriage on a short length of extrusion, for the close-up drawing."""
    x0, x1 = CX - 45, CX + 45
    parts = [rod_holder(CX), *carriage(CX).values(), extrusion(x0, x1)]
    for y in (BELT_BACK_Y, BELT_FRONT_Y):
        parts.append(box(x0, x1, y - 0.7, y + 0.7, BELT_Z - 3, BELT_Z + 3))
    parts.append(cyl_x(SY, ROD_Z + ROD_R + STRING_R, STRING_R, x0 - 20, x1 + 20))
    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(560 * vw / vh))
        h = round(w * vh / vw)
        figures.append(f'<svg x="{600 - w / 2:.0f}" y="{y}" width="{w}" height="{h}" viewBox="{view_box}">{body}</svg>')
        figures.append(f'<text x="600" y="{y + h + 34}" font-size="15" letter-spacing="2" text-anchor="middle" fill="#111">FIG. {i}   {caption}</text>')
        y += h + 80
    height = y
    path.write_text(
        f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 {height}" width="1200" height="{height}" '
        f'font-family="Georgia, \'Times New Roman\', serif">\n'
        f"<title>{title}</title>\n"
        f'<rect width="1200" height="{height}" fill="#fff"/>\n'
        '<text x="40" y="48" font-size="13" letter-spacing="4" fill="#111">THE MACHINE</text>\n'
        f'<text x="40" y="76" font-size="22" fill="#111">{title}</text>\n'
        f'<text x="1160" y="48" font-size="12" text-anchor="end" fill="#111">{note}</text>\n'
        '<text x="1160" y="66" font-size="12" text-anchor="end" fill="#111">2026-10-19</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,
        "Lapless Steel: the slide",
        "Drawn from the CAD model.",
        [
            (carriage_closeup(), (CX - 140, -190, 160), (CX, -5, 45), "ONE PRINTED PIECE ON THE PLATE HOLDS THE ROD UNDER THE STRING AND GRIPS THE BELT"),
        ],
    )


if __name__ == "__main__":
    main()
