"""
Motor Pick: a stepper turns a small wheel of four guitar picks beside a
string. A quarter turn sweeps one pick through the string, a single pluck;
spinning is tremolo; parked, the string sits untouched between two picks. It
clamps to any string build's extrusion and has its own ESP32, so it plays
alone or alongside the element it's mounted on.

Run from the cad/ folder:

    uv run motor-pick/model.py

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

The unit is shown on a short length of Lapless Steel's extrusion and string,
for scale. A 2020 post stands in front of the host's front face, held by two
corner brackets in its upper slot. The motor's L-bracket bolts to the post's
back face, above the host, so the shaft runs along the string, over it and a
little behind. The picks sweep down through the string, mostly sideways, the
way a hand plucks.

Coordinates, in millimeters, as in Lapless Steel: X runs along the string.
Y runs across, from the host's front face (0) to its back (20). Z is up; the
host's extrusion stands from 0 to 40, and the string runs at Y = 10, Z = 63.
The front face of the motor bracket's plate is at X = 0, and the wheel sits
on the +X side of it, toward the viewer in the site's usual view.

Only the hub 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.
"""

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

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

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

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

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

TOP = 40.0  # the host: 20 x 40 mm V-slot standing on its 20 mm edge, Y 0..20, Z 0..40
SY, SZ = 10.0, 63.0  # the host's string, 23 mm above its extrusion, as on Lapless Steel
STRING_R = 0.46
DRAWN_R = 0.8  # the string is drawn thicker than life, and square, so the viewer shows it
HOST_X = (-140.0, 160.0)  # the length of host drawn

# The wheel: four picks in slots in a printed hub on the motor's shaft.
PICKS = 4
PICK_LEN, PICK_W, PICK_T = 30.0, 26.0, 0.7  # a standard medium pick, the 351 shape
HUB_R = 17.0
HUB_LEN = 26.0  # as long as a pick is wide, so no pick overhangs the hub
HUB_GAP = 5.0  # between the bracket's plate and the hub, for the Hall sensor
SLOT_IN = 6.0  # each slot runs from this radius out to the rim
SLOT_W = 1.6  # takes picks up to about 1.2 mm; a set screw pinches it
INSERT = HUB_R - SLOT_IN  # 11 mm of each pick sits in its slot
TIP_R = SLOT_IN + PICK_LEN  # the picks' tips, 36 mm from the shaft
CATCH = 1.5  # how far each tip reaches past the string
SCREW_R = 11.5  # the pinch screws cross each slot at this radius
MAGNET_R, MAGNET_D, MAGNET_T = 13.5, 5.0, 2.0  # a small disc magnet in the hub's back face, for homing

# The motor: a 40 mm NEMA 17 on a steel L-bracket. The bracket's leg is on the
# post's back face, so the shaft sits 3 mm (the leg) plus half the motor behind it.
NEMA = 42.3
MOTOR_LEN = 40.0
PLATE_T = 3.0
SHAFT_Y = PLATE_T + NEMA / 2
# The shaft rises until the tips reach just CATCH past the string.
SHAFT_Z = SZ + math.sqrt((TIP_R - CATCH) ** 2 - (SY - SHAFT_Y) ** 2)
SHAFT_LEN = 24.0  # beyond the motor's face
HUB_X = (HUB_GAP, HUB_GAP + HUB_LEN)  # along X, the motor side first
HUB_MID = sum(HUB_X) / 2

# Where the string sits as seen from the shaft, and where the picks park:
# the string halfway between two of them.
STRING_ANGLE = math.degrees(math.atan2(SZ - SHAFT_Z, SY - SHAFT_Y))
PARK = [STRING_ANGLE + 180 / PICKS + k * 360 / PICKS for k in range(PICKS)]
MAGNET_ANGLE = STRING_ANGLE + 180  # between two slots, up and toward the back; the Hall sensor sits here
SET_SCREW_ANGLE = STRING_ANGLE + 90  # the screw that grips the shaft, between two other slots

# The post: 2020 V-slot, standing on the table in front of the host's front face.
POST_X = (-32.0, -12.0)  # under the bracket's leg
POST_Y = (-20.0, 0.0)
POST_Z = (-20.0, 130.0)  # 150 mm; the host stands on 20 mm feet, as Lapless Steel does
BRACKET_Z = (20.0, 40.0)  # the corner brackets, on the host's upper front slot

M3_TAP, M5 = 1.25, 2.75

# ---------------------------------------------------------------------------
# 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 rod(p0, p1, r):
    """A round rod from p0 to p1."""
    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 * Cylinder(r, length, align=(Align.CENTER, Align.CENTER, Align.MIN))


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 about_shaft(angle, shape):
    """Turn a shape drawn at angle 0 (reaching along +Y from the shaft) to angle degrees about the shaft."""
    return Pos(0, SHAFT_Y, SHAFT_Z) * Rot(angle, 0, 0) * Pos(0, -SHAFT_Y, -SHAFT_Z) * shape


def at_shaft(angle, r):
    """The point r from the shaft at angle degrees, in Y and Z."""
    a = math.radians(angle)
    return SHAFT_Y + r * math.cos(a), SHAFT_Z + r * math.sin(a)


def hull(points):
    """Convex hull of 2D points, counterclockwise."""
    pts = sorted(set(points))

    def half(seq):
        out = []
        for p in seq:
            while len(out) >= 2 and (out[-1][0] - out[-2][0]) * (p[1] - out[-2][1]) - (out[-1][1] - out[-2][1]) * (p[0] - out[-2][0]) <= 0:
                out.pop()
            out.append(p)
        return out

    lower, upper = half(pts), half(reversed(pts))
    return lower[:-1] + upper[:-1]


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


def pick_hub():
    """A drum on the motor's shaft with a slot for each pick, running its whole
    length. A pick slides into its slot, round end first, and an M3 set screw
    threaded into the plastic pinches it against the slot's far wall, so each
    pick can be slid in or out to set how far it reaches. Another set screw
    grips the shaft's flat, and a small magnet in the back face passes the Hall
    sensor once a turn."""
    h = cyl_x(SHAFT_Y, SHAFT_Z, HUB_R, *HUB_X)
    h -= cyl_x(SHAFT_Y, SHAFT_Z, 2.6, HUB_X[0] - 1, HUB_X[1] + 1)  # 5 mm shaft, a little loose
    lead = HUB_X[0] + 7.0  # the shaft's set screw, where the shaft is sure to reach
    for k, angle in enumerate(PARK):
        h -= about_shaft(angle, box(HUB_X[0] - 1, HUB_X[1] + 1, SHAFT_Y + SLOT_IN, SHAFT_Y + HUB_R + 1, SHAFT_Z - SLOT_W / 2, SHAFT_Z + SLOT_W / 2))
        # The pinch screw: across the slot at SCREW_R, in from the rim on one side.
        reach = math.sqrt(HUB_R**2 - SCREW_R**2) + 1
        h -= about_shaft(angle, rod((HUB_MID, SHAFT_Y + SCREW_R, SHAFT_Z), (HUB_MID, SHAFT_Y + SCREW_R, SHAFT_Z + reach), M3_TAP))
    h -= about_shaft(SET_SCREW_ANGLE, rod((lead, SHAFT_Y, SHAFT_Z), (lead, SHAFT_Y + HUB_R + 1, SHAFT_Z), M3_TAP))
    my, mz = at_shaft(MAGNET_ANGLE, MAGNET_R)
    h -= cyl_x(my, mz, MAGNET_D / 2 + 0.1, HUB_X[0] - 1, HUB_X[0] + MAGNET_T + 0.2)  # a press fit
    return h


# (name, builder, rotation that lays it flat for printing, what it is)
PRINTED = [
    ("pick-hub", pick_hub, Rot(0, 90, 0), "Pick hub: holds four picks on the motor's shaft, and the homing magnet"),
]


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


def pick_outline():
    """A standard pick: a round end and a rounded tip, joined, in (radius, along X)."""
    big_r, small_r = PICK_W / 2, 3.0
    big_c, small_c = SLOT_IN + big_r, TIP_R - small_r
    pts = []
    for i in range(48):
        a = 2 * math.pi * i / 48
        pts.append((round(big_c + big_r * math.cos(a), 4), round(big_r * math.sin(a), 4)))
        pts.append((round(small_c + small_r * math.cos(a), 4), round(small_r * math.sin(a), 4)))
    return hull(pts)


def picks():
    """The four picks, each in its slot at its parked angle."""
    outline = [(HUB_MID + w, SHAFT_Y + u, 0) for u, w in pick_outline()]
    flat = extrude(Face(Wire.make_polygon(outline, close=True)), PICK_T)
    one = Pos(0, 0, SHAFT_Z - PICK_T / 2) * flat
    return Compound(children=[about_shaft(a, one) for a in PARK])


def extrusion_2040(x0, x1):
    """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 post(z0=POST_Z[0], z1=POST_Z[1]):
    """20 x 20 mm V-slot standing upright, with a slot in each face."""
    (x0, x1), (y0, y1) = POST_X, POST_Y
    cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
    p = box(x0, x1, y0, y1, z0, z1)
    p -= box(cx - 3.1, cx + 3.1, y0, y0 + 6, z0, z1) + box(cx - 3.1, cx + 3.1, y1 - 6, y1, z0, z1)
    p -= box(x0, x0 + 6, cy - 3.1, cy + 3.1, z0, z1) + box(x1 - 6, x1, cy - 3.1, cy + 3.1, z0, z1)
    p -= cyl_z(cx, cy, 2.1, z0, z1)
    return p


def corner_brackets():
    """Two cast corner brackets, one each side of the post, in the inside corners it
    makes with the host's front face. One leg bolts into the host's upper front
    slot, the other into the post's side slot."""
    z0, z1 = BRACKET_Z
    out = []
    for side, face in ((-1, POST_X[0]), (1, POST_X[1])):
        host_leg = box(*sorted((face, face + side * 20)), -3, 0, z0, z1)
        post_leg = box(*sorted((face, face + side * 3)), POST_Y[0], 0, z0, z1)
        web = box(*sorted((face, face + side * 17)), -17, 0, z0 + 8.5, z1 - 8.5)
        web -= box(*sorted((face + side * 3, face + side * 18)), -18, -3, z0, z1)  # leave only the outer edges
        out.append(host_leg + post_leg + web)
    return Compound(children=out)


def motor_parts():
    """The stepper, its L-bracket, the shaft and the Hall sensor glued to the bracket's face."""
    zc = SHAFT_Z
    plate = box(-PLATE_T, 0, 0, SHAFT_Y + NEMA / 2 + 1, zc - 22.5, zc + 22.5)
    plate -= cyl_x(SHAFT_Y, zc, 11.5, -PLATE_T - 1, 1)
    for dy in (-15.5, 15.5):
        for dz in (-15.5, 15.5):
            plate -= cyl_x(SHAFT_Y + dy, zc + dz, 1.6, -PLATE_T - 1, 1)
    leg = box(-42, 0, 0, PLATE_T, zc - 30, zc + 30)
    for dz in (-26, 26):  # slots beside the motor, along the shaft; M5 screws into the post's back slot
        leg -= box(-34, -10, -1, PLATE_T + 1, zc + dz - 2.75, zc + dz + 2.75)
    bracket = plate + leg

    motor = box(-PLATE_T - MOTOR_LEN, -PLATE_T, PLATE_T, PLATE_T + NEMA, zc - NEMA / 2, zc + NEMA / 2)
    motor += cyl_x(SHAFT_Y, zc, 11, -PLATE_T, -1)  # the boss, in the bracket's hole
    shaft = cyl_x(SHAFT_Y, zc, 2.5, -1, SHAFT_LEN - PLATE_T)

    # The A3144 Hall sensor: a small flat package glued to the plate's face at the magnet's radius,
    # its three legs bent out along the face.
    sensor = box(0, 1.5, MAGNET_R - 2, MAGNET_R + 2, -1.5, 1.5)
    for dz in (-1.27, 0, 1.27):
        sensor += box(0.2, 0.6, MAGNET_R + 2, MAGNET_R + 16, dz - 0.2, dz + 0.2)
    sensor = about_shaft(MAGNET_ANGLE, Pos(0, SHAFT_Y, SHAFT_Z) * sensor)

    my, mz = at_shaft(MAGNET_ANGLE, MAGNET_R)
    magnet = cyl_x(my, mz, MAGNET_D / 2, HUB_X[0], HUB_X[0] + MAGNET_T)
    return bracket, motor, shaft, sensor, magnet


def bought_parts():
    bracket, motor, shaft, sensor, magnet = motor_parts()
    parts = [
        ("nema17", motor, (0.2, 0.2, 0.22)),
        ("motor-bracket", bracket, (0.55, 0.57, 0.6)),
        ("shaft", shaft, (0.75, 0.75, 0.78)),
        ("hall-sensor", sensor, (0.15, 0.15, 0.15)),
        ("magnet", magnet, (0.7, 0.7, 0.72)),
        ("picks", picks(), (0.93, 0.88, 0.76)),
        ("post-2020", post(), (0.78, 0.8, 0.83)),
        ("corner-brackets", corner_brackets(), (0.55, 0.57, 0.6)),
    ]

    # The stop button in its housing on the post's top end, where a hand finds it first.
    cx = sum(POST_X) / 2
    stop = box(cx - 20, cx + 20, -30, 10, POST_Z[1], POST_Z[1] + 30) + cyl_z(cx, -10, 14, POST_Z[1] + 30, POST_Z[1] + 42)
    parts.append(("stop-button", stop, (0.85, 0.7, 0.2)))

    # On the host's back face, wherever there's room: the electronics in a small box, and the
    # battery adapter with its pack.
    parts += [
        ("electronics-box", box(30, 150, 22, 62, -15, 25) + cyl_x(42, 2, 5, 150, 156), (0.3, 0.3, 0.32)),
        ("battery-adapter", box(-120, -40, 20, 40, 1, 31), (0.3, 0.3, 0.32)),
        ("battery", box(-130, -30, 41, 111, -18, 40), (0.6, 0.2, 0.15)),
    ]

    # A short length of the host, for scale: its extrusion and its string.
    parts.append(("host-2040", extrusion_2040(*HOST_X), (0.86, 0.87, 0.89)))
    parts.append(("host-string", strand((HOST_X[0], SY, SZ), (HOST_X[1], SY, SZ), DRAWN_R), (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


WHEEL = ["printed:pick-hub", "bought:picks", "bought:magnet", "bought:shaft"]


def motion():
    """Park, two plucks one way, one back, tremolo, park. Turning by a negative
    angle moves the pick toward the front as it crosses the string."""
    q = 360 / PICKS
    t = -90 - 3 * 360  # three turns of tremolo, ending parked
    write_motion(
        OUT / "motor-pick-motion.json",
        duration=9,
        tracks=[
            turn(
                WHEEL,
                (1, 0, 0),
                (0, SHAFT_Y, SHAFT_Z),
                [(0, 0), (0.9, 0), (1.2, -q), (2.0, -q), (2.3, -2 * q), (3.1, -2 * q), (3.4, -q), (4.4, -q), (7.2, t), (9, t)],
            ),
        ],
        captions=[
            (0, "Parked, the string sits between two picks, and nothing touches it."),
            (0.9, "A quick quarter turn sweeps the next pick through the string: one pluck. Slowed down here, it takes about 30 ms."),
            (3.1, "Turning back plucks the other way, like an upstroke."),
            (4.4, "Spinning, every pick plucks in turn. That's tremolo, up to 20 or more plucks a second."),
            (7.2, "It parks between two picks again, and the string rings on."),
        ],
    )


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

    assembly = []
    print(f"shaft at Y {SHAFT_Y:.2f}, Z {SHAFT_Z:.2f}; string at {STRING_ANGLE:.1f} degrees from it; tips {TIP_R:.0f} mm out")
    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="motor-pick")
    export_step(model, OUT / "motor-pick.step")
    export_gltf(model, OUT / "motor-pick.glb", unit=Unit.MM, binary=True, linear_deflection=0.08, angular_deflection=0.3)
    compact_glb(OUT / "motor-pick.glb")
    motion()
    shutil.copyfile(__file__, OUT / "motor-pick-cad.py")

    render(DRAWINGS / "motor-pick-render.svg")
    print("written to", OUT)


def wheel_closeup():
    """The wheel, the bracket's plate and a short length of the host and its string, for the end-on drawing."""
    bracket, motor, shaft, sensor, _ = motor_parts()
    x0, x1 = POST_X[0] - 10, HUB_X[1] + 10
    parts = [pick_hub(), picks(), bracket, motor, sensor, extrusion_2040(x0, x1), post(BRACKET_Z[0], POST_Z[1])]
    parts.append(cyl_x(SY, SZ, 1.0, x0, x1))  # the string, drawn a little thicker so it shows
    return Compound(children=parts)


def unit_view():
    """The whole unit on its short length of host, for the drawing."""
    parts = [pick_hub()] + [shape for _, shape, _ in bought_parts()]
    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-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):
    centre = (HUB_MID, SHAFT_Y, SHAFT_Z)
    sheet(
        path,
        "Motor Pick: the wheel",
        "Drawn from the CAD model.",
        [
            (unit_view(), (560, -740, 490), (0, 20, 60), "A POST IN FRONT OF THE HOST HOLDS THE MOTOR OVER THE STRING"),
            (wheel_closeup(), (600, SHAFT_Y, SHAFT_Z), centre, "SEEN ALONG THE STRING: PARKED, THE STRING SITS BETWEEN TWO PICKS"),
        ],
    )


if __name__ == "__main__":
    main()
