"""
Windmill Arpeggio Machine: a wheel of eight single-string arms turns past a
pick. Each arm is one step of a step sequencer you can watch. A stepper turns
the wheel in time with the beat, a servo swings the pick clear for a rest, and
a felt pad quiets each string just before it comes round again.

Run from the cad/ folder:

    uv run windmill-arpeggio-machine/model.py

It writes, for the site:
    site/public/files/windmill-arpeggio-machine/stl/hub-cap.stl                         the one printed part, laid flat
    site/public/files/windmill-arpeggio-machine/hub-template.svg                       a full-size drilling template for the hub disc
    site/public/files/windmill-arpeggio-machine/windmill-arpeggio-machine.step         the whole assembly, for editing
    site/public/files/windmill-arpeggio-machine/windmill-arpeggio-machine.glb          the 3D model shown on the page
    site/public/files/windmill-arpeggio-machine/windmill-arpeggio-machine-motion.json  how it moves, for the Play button
    site/public/files/windmill-arpeggio-machine/windmill-arpeggio-machine-cad.py       this script
    site/src/content/instruments/windmill-arpeggio-machine/drawings/windmill-arpeggio-machine-render.svg

Each arm is a short length of 2020 V-slot carrying one guitar string, with the
same tuner, bridge and string heights as Lapless Steel: a sealed tuner in a
drilled aluminum angle near the hub, and a one-piece bridge on standoffs at the
tip. Eight arms bolt to a plywood disc on a 10 mm axle, which turns in two
pillow-block bearings on top of a V-slot post. A stepper drives it through a
4 to 1 belt. Only sound leaves the wheel: the pickups are wired together to a
jack in the printed hub cap, and a wireless guitar sender plugs into it.

Coordinates, in millimeters: X runs left to right as you face the wheel, Y from
the front (the audience, negative) to the back, and Z is up from the floor. The
axle runs along Y. The wheel turns clockwise as you face it, so at the bottom
each arm arrives from the right.

Each arm is drawn in its own frame, the way Lapless Steel draws its module:
x along the arm from the axle outward, y across it, z up from its front face.
on_arm() puts that frame onto the wheel.

Only the hub cap is printed. Everything else is bought or cut, 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

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

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

REPO = HERE.parent.parent
NAME = "windmill-arpeggio-machine"
OUT = REPO / "site/public/files" / NAME
DRAWINGS = REPO / "site/src/content/instruments" / NAME / "drawings"

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

ARMS = 8  # steps per turn; at 120 beats a minute in eighth notes the wheel turns at 30 RPM

# One arm, in its own frame: a 2020 V-slot from ARM_IN to ARM_OUT, its front face at z = 0.
ARM_IN, ARM_OUT = 40.0, 465.0
STRING_Z = 23.0  # the string's height above the arm, as on Lapless Steel
STRING_R = 0.46  # a wound .036; the thinnest is .010
DRAWN_R = 0.8  # the strings are drawn thicker than life, and square, so the viewer shows them
POST_R = 95.0  # the tuner post, which is the nut
SADDLE_R = 425.0  # the bridge saddle's crest; 330 mm of string between them
PICKUP_R = 398.0  # the pickup, between the pick and the bridge
PICK_R = 365.0  # where the pick catches each string, 60 mm in from the saddle

# 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 = SADDLE_R + 12.0
BRIDGE_HOLE = SADDLE_R + 25.0
BRIDGE_BASE = STRING_Z - STRING_R - 11.0

# The stand. The axle sits on two pillow blocks on two short bars across the top of the post.
BASE_TOP = 20.0  # the base is 2040 V-slot lying flat
BAR_TOP = 580.0  # the post's top, and the top of the two bearing bars
AXLE_Z = BAR_TOP + 18.0  # a 10 mm pillow block holds its shaft about 18 mm up
POST_Y = (60.0, 100.0)  # 2040 post, 20 wide across, 40 deep front to back
FRONT_BAR_Y, BACK_BAR_Y = (40.0, 60.0), (100.0, 120.0)

# The wheel, front to back along the axle.
Y_FACE = -20.0  # the arms' front faces; the arms lie on the disc's front face, at y = -20..0
Y_STRING = Y_FACE - STRING_Z  # every string lies in this one plane, so one pick reaches them all
HUB_R, HUB_T = 125.0, 12.0  # birch plywood disc, 250 mm across
CAP_R, CAP_H = 34.0, 38.0  # the printed hub cap on the disc's front, which holds the jack
AXLE = (0.0, 150.0)  # 10 mm steel shaft, flush with the disc's front face

# The drive: 20-tooth pulley on the motor, 80 on the axle, a 400 mm GT2 loop, 6 mm wide.
BELT_Y = 131.5  # the belt's middle
R_BIG, R_SMALL = 80 * 2 / (2 * math.pi), 20 * 2 / (2 * math.pi)
BELT_LOOP = 400.0
MOTOR_X = 34.0  # the motor hangs beside the post on an L-bracket in its side slot


def centre_distance(loop, R, r):
    """Pulley centers' distance for a closed belt of this length."""
    lo, hi = R + r, loop / 2
    for _ in range(60):
        c = (lo + hi) / 2
        length = 2 * math.sqrt(c * c - (R - r) ** 2) + math.pi * (R + r) + 2 * (R - r) * math.asin((R - r) / c)
        lo, hi = (c, hi) if length < loop else (lo, c)
    return c


MOTOR_Z = AXLE_Z - math.sqrt(centre_distance(BELT_LOOP, R_BIG, R_SMALL) ** 2 - MOTOR_X**2)

# The pick, at six o'clock on a servo. Its shaft runs across the wheel's face (along X);
# the horn points down, so turning it swings the pick forward, clear of the strings.
PICK_Z = AXLE_Z - PICK_R
SERVO_Y, SERVO_Z = -68.0, PICK_Z + 20.0
PICK_CATCH = 1.5  # how far past each string the pick's tip reaches
REST = -25.0  # degrees: the servo's turn that lifts the pick clear for a rest

# The felt damper, 30 degrees before the pick, so each note rings for nearly a whole turn.
DAMP_ANGLE, DAMP_R = -30.0, 300.0

MAGNET_R = 118.0  # the home magnet, on the disc's back, behind arm 1

M5 = 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 prism_y(x, z, r, y0, y1, sides=8):
    """A round bar drawn with flats, so its length shows in the viewer, which only draws creases."""
    return Pos(x, y0, z) * Rot(-90, 0, 0) * extrude(RegularPolygon(r, sides), amount=y1 - y0)


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 vslot(x0, x1, y0, y1, z0, z1):
    """Aluminum V-slot extrusion, simplified: a plain slot every 20 mm along each long face."""
    sizes = (x1 - x0, y1 - y0, z1 - z0)
    along = sizes.index(max(sizes))
    e = box(x0, x1, y0, y1, z0, z1)
    lo, hi = (x0, y0, z0), (x1, y1, z1)
    for face in range(3):
        if face == along:
            continue
        other = 3 - along - face
        for c in range(int(round((hi[other] - lo[other]) / 20))):
            mid = lo[other] + 10 + 20 * c
            for at_low in (True, False):
                a, b = [list(lo), list(hi)]
                a[other], b[other] = mid - 3.1, mid + 3.1
                if at_low:
                    b[face] = lo[face] + 6
                else:
                    a[face] = hi[face] - 6
                e -= box(a[0], b[0], a[1], b[1], a[2], b[2])
    return e


def corner_bracket(x0, x1, y0, y1, z0, z1, keep):
    """A corner bracket for 20 mm V-slot, as a plain L inside this box. keep names its two
    4 mm legs by the sides of the box they lie on: '+y-z' is a leg on the high-Y side and one on the low-Z side."""
    lo, hi = [x0, y0, z0], [x1, y1, z1]
    a, b = list(lo), list(hi)
    for sign, axis in zip(keep[0::2], keep[1::2]):
        i = "xyz".index(axis)
        if sign == "+":
            b[i] = hi[i] - 4
        else:
            a[i] = lo[i] + 4
    return box(x0, x1, y0, y1, z0, z1) - box(a[0], b[0], a[1], b[1], a[2], b[2])


# An arm's own frame, placed for an arm pointing straight down: its x runs down from the
# axle, its y runs to the right, and its z comes forward, toward the audience.
ARM_FRAME = Plane(origin=(0, Y_FACE, AXLE_Z), x_dir=(0, 0, -1), z_dir=(0, -1, 0)).location


def arm_angle(k):
    """Where arm k sits in the model, in degrees about the axle from straight down. Arm 1 (k = 0)
    is half a step short of the pick; the others follow it round, each one step further back."""
    return -180 / ARMS - k * 360 / ARMS


def on_wheel(shape, angle):
    """Turn a shape about the axle, clockwise as you face the wheel."""
    return Pos(0, 0, AXLE_Z) * Rot(0, angle, 0) * Pos(0, 0, -AXLE_Z) * shape


def on_arm(shape, angle):
    """A shape drawn in an arm's frame, placed on the wheel at this angle."""
    return on_wheel(ARM_FRAME * shape, angle)


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


def hub_cap():
    """A cup on the front of the hub disc, open toward it. It holds the output jack on the
    axle's line, so the wireless sender plugged into it keeps the wheel balanced, and it covers
    the joints where the eight pickups' wires meet. Each wire comes in through a notch in its rim,
    under its own arm. Four M4 bolts hold it through the disc."""
    cap = cyl_y(0, AXLE_Z, CAP_R, -CAP_H, 0) - cyl_y(0, AXLE_Z, CAP_R - 3, -CAP_H + 3.5, 0)
    for a in range(4):
        x, z = 26 * math.sin(math.radians(90 * a)), AXLE_Z + 26 * math.cos(math.radians(90 * a))
        cap += cyl_y(x, z, 4.5, -CAP_H, 0)
        cap -= cyl_y(x, z, 2.25, -CAP_H - 1, 1)
        cap -= cyl_y(x, z, 4.2, -CAP_H - 1, -CAP_H + 3)  # sinks the bolt's head
    cap -= cyl_y(0, AXLE_Z, 4.9, -CAP_H - 1, -CAP_H + 5)  # the jack's 3/8 inch bushing
    for k in range(ARMS):
        notch = box(-3, 3, -6, 1, AXLE_Z - CAP_R - 1, AXLE_Z - CAP_R + 4)
        cap -= on_wheel(notch, arm_angle(k))
    return cap


# (name, builder, rotation that lays it flat for printing, what it is)
PRINTED = [
    ("hub-cap", hub_cap, Rot(90, 0, 0), "Hub cap: holds the output jack on the axle's line, and covers the pickups' wires where they meet"),
]


# ---------------------------------------------------------------------------
# One arm, in its own frame
# ---------------------------------------------------------------------------


def arm_parts():
    """Everything on one arm, named as the model names it. The string hardware is Lapless Steel's."""
    p, s = POST_R, SADDLE_R
    post_z = STRING_Z - 3 - STRING_R  # the string leaves the top of the tuner post

    angle = box(p - 20, p + 20, -10, 10, 0, 3) + box(p - 20, p + 20, 7, 10, 0, 40)
    angle -= cyl_y(p, post_z, 5, 6, 11)
    tuner = cyl_y(p, post_z, 3, -9, 7) + cyl_y(p, post_z, 6, 4, 7)
    tuner += box(p - 9, p + 13, 10, 22, post_z - 8, post_z + 8)
    tuner += cyl_x(16, post_z, 2, p + 13, p + 23) + box(p + 23, p + 31, 10, 22, post_z - 11, post_z + 11)  # its key

    bridge = box(BRIDGE_C - BRIDGE_LEN / 2, BRIDGE_C + BRIDGE_LEN / 2, -BRIDGE_W / 2, BRIDGE_W / 2, BRIDGE_BASE, BRIDGE_BASE + 3.5)
    bridge += box(s - 2, s + 2, -4, 4, BRIDGE_BASE + 3.5, STRING_Z - STRING_R)  # the saddle's crest
    bridge += box(s + 2, s + 18.5, -4, 4, BRIDGE_BASE + 3.5, BRIDGE_BASE + 4.5)
    bridge -= cyl_z(BRIDGE_HOLE, 0, 1.8, BRIDGE_BASE - 1, BRIDGE_BASE + 5)
    standoffs = Compound(children=[cyl_z(BRIDGE_C + d, 0, 2.75, 0, BRIDGE_BASE) for d in (-BRIDGE_SCREWS / 2, BRIDGE_SCREWS / 2)])
    washer = cyl_z(BRIDGE_HOLE, 0, 2.5, BRIDGE_BASE - 0.6, BRIDGE_BASE) + cyl_z(BRIDGE_HOLE, 0, 2.1, BRIDGE_BASE - 3.6, BRIDGE_BASE - 0.6)

    # A small single coil, the cigar-box guitar kind, on a foam pad, its middle pole under the string.
    pole_top = STRING_Z - STRING_R - 3
    pickup = box(PICKUP_R - 7, PICKUP_R + 7, -22, 22, 3, pole_top - 1)
    for y in (-11, 0, 11):
        pickup += cyl_z(PICKUP_R, y, 2.5, pole_top - 1, pole_top)

    path = [(p, 0, STRING_Z), (s, 0, STRING_Z), (BRIDGE_HOLE, 0, BRIDGE_BASE + 3.5), (BRIDGE_HOLE, 0, BRIDGE_BASE - 3)]
    string = Compound(children=[strand(a, b, DRAWN_R) for a, b in zip(path, path[1:])])

    return {
        "arms": (vslot(ARM_IN, ARM_OUT, -10, 10, -20, 0), (0.78, 0.8, 0.83)),
        "tuner-angles": (angle, (0.55, 0.57, 0.6)),
        "tuners": (tuner, (0.72, 0.72, 0.72)),
        "bridges": (bridge, (0.16, 0.16, 0.18)),
        "bridge-standoffs": (standoffs, (0.55, 0.57, 0.6)),
        "ball-ends": (washer, (0.72, 0.62, 0.35)),
        "pickups": (pickup, (0.12, 0.12, 0.12)),
        "pickup-foam": (box(PICKUP_R - 6, PICKUP_R + 6, -9, 9, 0, 3), (0.3, 0.3, 0.32)),
        "strings": (string, (0.85, 0.7, 0.3)),
    }


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


def wheel_parts():
    """Everything that turns: eight arms, the hub, the axle and its pulley, the jack and the sender."""
    groups = {}
    for name, (shape, rgb) in arm_parts().items():
        groups[name] = (Compound(children=[on_arm(shape, arm_angle(k)) for k in range(ARMS)]), rgb)

    parts = [(name, shape, rgb) for name, (shape, rgb) in groups.items()]
    parts += [
        ("hub-disc", cyl_y(0, AXLE_Z, HUB_R, 0, HUB_T) - cyl_y(0, AXLE_Z, 5, -1, HUB_T + 1), (0.82, 0.7, 0.52)),
        ("flange-coupling", cyl_y(0, AXLE_Z, 20, HUB_T, HUB_T + 6) + cyl_y(0, AXLE_Z, 11, HUB_T + 6, HUB_T + 26), (0.7, 0.7, 0.72)),
        ("axle", prism_y(0, AXLE_Z, 5.4, *AXLE), (0.75, 0.75, 0.78)),
    ]
    pulley = cyl_y(0, AXLE_Z, R_BIG - 0.3, BELT_Y - 3.5, BELT_Y + 3.5)
    pulley += cyl_y(0, AXLE_Z, R_BIG + 2.5, BELT_Y - 4.5, BELT_Y - 3.5) + cyl_y(0, AXLE_Z, R_BIG + 2.5, BELT_Y + 3.5, BELT_Y + 4.5)
    pulley += cyl_y(0, AXLE_Z, 12, BELT_Y + 4.5, BELT_Y + 12.5)  # its hub, with the set screws
    parts.append(("axle-pulley", pulley, (0.7, 0.7, 0.72)))

    # The home magnet: the magnet half of a door contact, on the disc's back, behind arm 1.
    magnet = box(-13.5, 13.5, HUB_T, HUB_T + 8, AXLE_Z - MAGNET_R - 7, AXLE_Z - MAGNET_R + 7)
    parts.append(("home-magnet", on_wheel(magnet, arm_angle(0)), (0.9, 0.9, 0.88)))

    # The output jack in the hub cap, and the wireless sender plugged into it, on the axle's line.
    jack = cyl_y(0, AXLE_Z, 4.75, -CAP_H - 2, -CAP_H + 6) + prism_y(0, AXLE_Z, 7, -CAP_H - 2.5, -CAP_H, sides=6)
    jack += box(-8, 8, -CAP_H + 6, -8, AXLE_Z - 6, AXLE_Z + 6)
    sender = cyl_y(0, AXLE_Z, 3.2, -CAP_H - 14, -CAP_H - 2.5) + box(-15, 15, -CAP_H - 82, -CAP_H - 14, AXLE_Z - 10, AXLE_Z + 10)
    parts += [("jack", jack, (0.7, 0.7, 0.72)), ("wireless-sender", sender, (0.2, 0.2, 0.22))]
    return parts


def hull_points(a, ra, b, rb, n=40):
    """The outline round two circles, as a belt wraps them: counterclockwise, in the XZ plane."""
    (ax, az), (bx, bz) = a, b
    d = math.hypot(bx - ax, bz - az)
    base = math.atan2(bz - az, bx - ax)
    g = math.acos((ra - rb) / d)
    pts = [(ax + ra * math.cos(t), az + ra * math.sin(t)) for t in (base + g + (2 * math.pi - 2 * g) * i / n for i in range(n + 1))]
    pts += [(bx + rb * math.cos(t), bz + rb * math.sin(t)) for t in (base - g + 2 * g * i / n for i in range(1, n))]
    return pts


def belt():
    big, small = (0.0, AXLE_Z), (MOTOR_X, MOTOR_Z)
    outline = Polygon(*hull_points(big, R_BIG + 0.75, small, R_SMALL + 0.75), align=None)
    outline -= Polygon(*hull_points(big, R_BIG - 0.75, small, R_SMALL - 0.75), align=None)
    return Pos(0, BELT_Y + 3, 0) * extrude(Plane.XZ * outline, amount=6)


def drive_parts():
    mx, mz = MOTOR_X, MOTOR_Z
    motor = box(mx - 21.15, mx + 21.15, 75, 115, mz - 21.15, mz + 21.15) + cyl_y(mx, mz, 11, 115, 117)
    pulley = cyl_y(mx, mz, R_SMALL - 0.3, BELT_Y - 3.5, BELT_Y + 3.5) + cyl_y(mx, mz, 6.5, 120, BELT_Y - 4.5)
    pulley += cyl_y(mx, mz, 8, BELT_Y - 4.5, BELT_Y - 3.5) + cyl_y(mx, mz, 8, BELT_Y + 3.5, BELT_Y + 4.5)
    pulley += cyl_y(mx, mz, 2.5, 117, BELT_Y + 6)  # the motor's shaft
    # A steel L-bracket: the motor's face bolts to one leg, the other lies on the post's side face,
    # in its back slot, and slides up and down to tension the belt.
    bracket = box(10, mx + 21.15, 115, 118, mz - 21.15, mz + 21.15) - cyl_y(mx, mz, 11.5, 114, 119)
    bracket += box(10, 13, 75, 118, mz - 21.15, mz + 21.15)
    return [
        ("nema17", motor, (0.2, 0.2, 0.22)),
        ("motor-pulley", pulley, (0.7, 0.7, 0.72)),
        ("motor-bracket", bracket, (0.55, 0.57, 0.6)),
        ("belt", belt(), (0.15, 0.15, 0.15)),
    ]


def pillow_block(yc):
    """A 10 mm pillow-block bearing, its two bolts in the bar's top slot."""
    pb = box(-33.5, 33.5, yc - 6.5, yc + 6.5, BAR_TOP, BAR_TOP + 11)
    pb += cyl_y(0, AXLE_Z, 15, yc - 6.5, yc + 6.5) + cyl_y(0, AXLE_Z, 11, yc - 8.5, yc + 8.5)
    return pb - cyl_y(0, AXLE_Z, 5, yc - 10, yc + 10)


def stand_parts():
    """The V-slot stand: a base, a post, and two short bars across its top that carry the bearings."""
    base = Compound(
        children=[
            vslot(-280, -240, -150, 200, 0, BASE_TOP),  # feet, front to back
            vslot(240, 280, -150, 200, 0, BASE_TOP),
            vslot(-240, 240, -120, -80, 0, BASE_TOP),  # front crossbar, under the pick and the damper
            vslot(-240, 240, *POST_Y, 0, BASE_TOP),  # back crossbar, under the post
        ]
    )
    plates = Compound(children=[box(*sorted((sx * 200, sx * 280)), y0, y1, BASE_TOP, BASE_TOP + 3) for sx in (-1, 1) for y0, y1 in ((-120, -80), POST_Y)])
    bars = Compound(children=[vslot(-50, 50, *FRONT_BAR_Y, BAR_TOP - 20, BAR_TOP), vslot(-50, 50, *BACK_BAR_Y, BAR_TOP - 20, BAR_TOP)])

    brackets = []
    for sx in (-1, 1):
        lo, hi = sorted((sx * 10, sx * 30))
        inner = "-x" if sx > 0 else "+x"
        brackets += [
            corner_bracket(lo, hi, 60, 80, BAR_TOP - 20, BAR_TOP, "-y" + inner),  # front bearing bar to the post
            corner_bracket(lo, hi, 80, 100, BAR_TOP - 20, BAR_TOP, "+y" + inner),  # back bearing bar to the post
            corner_bracket(lo, hi, 60, 80, BASE_TOP, BASE_TOP + 20, inner + "-z"),  # post to the base
            corner_bracket(lo, hi, 80, 100, BASE_TOP, BASE_TOP + 20, inner + "-z"),
        ]
    for px in (-40, 150):  # the pick's and the damper's posts, to the front crossbar
        brackets += [
            corner_bracket(px + 10, px + 30, -110, -90, BASE_TOP, BASE_TOP + 20, "-x-z"),
            corner_bracket(px - 30, px - 10, -110, -90, BASE_TOP, BASE_TOP + 20, "+x-z"),
        ]

    return [
        ("base", base, (0.78, 0.8, 0.83)),
        ("joining-plates", plates, (0.55, 0.57, 0.6)),
        ("post", vslot(-10, 10, *POST_Y, BASE_TOP, BAR_TOP), (0.78, 0.8, 0.83)),
        ("bearing-bars", bars, (0.78, 0.8, 0.83)),
        ("pillow-blocks", Compound(children=[pillow_block(sum(FRONT_BAR_Y) / 2), pillow_block(sum(BACK_BAR_Y) / 2)]), (0.45, 0.47, 0.5)),
        ("corner-brackets", Compound(children=brackets), (0.55, 0.57, 0.6)),
    ]


def pick_parts():
    """The pick at six o'clock, on an MG90S servo whose shaft runs across the wheel's face.
    Its horn points down; a mini binder clip on its tip holds a guitar pick, edge on to the
    strings, reaching back into their plane."""
    y, z = SERVO_Y, SERVO_Z
    case = box(-27.7, -5, y - 6.1, y + 6.1, z - 6, z + 16.5)
    case += box(-11.5, -9, y - 6.1, y + 6.1, z - 11, z + 21.5)  # its mounting tabs
    case += cyl_x(y, z, 2.4, -5, -3.5)
    bracket = box(-50, -27.7, -90, -87, z - 20, z + 25) + box(-30.7, -27.7, -90, y + 6.1, z - 11, z + 21.5)
    horn = cyl_x(y, z, 3.5, -3.5, -1.5) + box(-3.5, -1.5, y - 2.5, y + 2.5, PICK_Z - 1, z)
    clip = box(-4.5, 1, y - 3, y + 3, PICK_Z - 4, PICK_Z + 4)
    tip = Y_STRING + PICK_CATCH
    outline = Polygon((y, PICK_Z - 7), (tip - 7, PICK_Z - 5), (tip, PICK_Z), (tip - 7, PICK_Z + 5), (y, PICK_Z + 7), align=None)
    pick = Pos(-0.7, 0, 0) * extrude(Plane.YZ * outline, amount=0.7)
    return [
        ("pick-post", vslot(-50, -30, -110, -90, BASE_TOP, PICK_Z + 45), (0.78, 0.8, 0.83)),
        ("servo-bracket", bracket, (0.55, 0.57, 0.6)),
        ("servo-mg90s", case, (0.25, 0.3, 0.45)),
        ("servo-horn", horn + clip, (0.9, 0.9, 0.88)),
        ("pick", pick, (0.9, 0.85, 0.75)),
    ]


def damper_parts():
    """A strip of thick felt on a small aluminum angle, kissing each string as it passes,
    30 degrees before the pick. Where it sits sets how long each note rings."""
    felt = box(DAMP_R - 25, DAMP_R + 25, -10, 10, STRING_Z + STRING_R - 0.3, STRING_Z + 6.5)
    plate = box(DAMP_R - 28, DAMP_R + 28, -12, 12, STRING_Z + 6.5, STRING_Z + 9.5)
    x = DAMP_R * math.sin(math.radians(-DAMP_ANGLE))
    z = AXLE_Z - DAMP_R * math.cos(math.radians(DAMP_ANGLE))
    holder = on_arm(plate, DAMP_ANGLE) + box(x - 6, x + 6, -90, Y_FACE - STRING_Z - 8, z - 8, z + 8)
    return [
        ("damper-post", vslot(140, 160, -110, -90, BASE_TOP, z + 12), (0.78, 0.8, 0.83)),
        ("damper-holder", holder, (0.55, 0.57, 0.6)),
        ("damper-felt", on_arm(felt, DAMP_ANGLE), (0.3, 0.3, 0.32)),
    ]


def control_parts():
    """The home sensor on the post's front face, and the electronics on the base and the post."""
    sensor_z = AXLE_Z - MAGNET_R
    sensor = box(-13.5, 13.5, HUB_T + 13, HUB_T + 21, sensor_z - 7, sensor_z + 7)
    sensor_bracket = box(-10, 10, HUB_T + 13, POST_Y[0], sensor_z - 10, sensor_z - 7) + box(-10, 10, POST_Y[0] - 3, POST_Y[0], sensor_z - 40, sensor_z - 7)
    return [
        ("home-sensor", sensor, (0.9, 0.9, 0.88)),
        ("sensor-bracket", sensor_bracket, (0.55, 0.57, 0.6)),
        ("electronics-box", box(40, 160, *POST_Y, BASE_TOP, BASE_TOP + 40) + cyl_y(100, BASE_TOP + 20, 5, POST_Y[1], POST_Y[1] + 6), (0.3, 0.3, 0.32)),
        ("stop-button", box(-25, 25, POST_Y[1], POST_Y[1] + 50, 280, 330) + cyl_y(0, 305, 14, POST_Y[1] + 50, POST_Y[1] + 62), (0.85, 0.7, 0.2)),
        ("battery-adapter", box(-200, -120, *POST_Y, BASE_TOP, BASE_TOP + 30), (0.3, 0.3, 0.32)),
        ("battery", box(-215, -105, 45, 115, BASE_TOP + 30, BASE_TOP + 92), (0.6, 0.2, 0.15)),
    ]


def bought_parts():
    return wheel_parts() + drive_parts() + stand_parts() + pick_parts() + damper_parts() + control_parts()


# ---------------------------------------------------------------------------
# Motion
# ---------------------------------------------------------------------------

WHEEL = [
    "bought:arms",
    "bought:tuner-angles",
    "bought:tuners",
    "bought:bridges",
    "bought:bridge-standoffs",
    "bought:ball-ends",
    "bought:pickups",
    "bought:pickup-foam",
    "bought:strings",
    "bought:hub-disc",
    "bought:flange-coupling",
    "bought:axle",
    "bought:axle-pulley",
    "bought:home-magnet",
    "bought:jack",
    "bought:wireless-sender",
    "printed:hub-cap",
]
PLUCKER = ["bought:servo-horn", "bought:pick"]
TURN_SECONDS = 2.0  # 30 RPM: a bar of eighth notes at 120 beats a minute


def arrival(turn_no, k):
    """When arm k reaches the pick, on the given turn of the loop."""
    return TURN_SECONDS * (turn_no + (180 / ARMS + k * 360 / ARMS) / 360)


def motion():
    """Four turns at a steady speed: two that play every step, then rests on every other
    step, then two rests in a row. The servo lifts the pick for each rest."""
    turns = 4
    rests = {2: [1, 3, 5, 7], 3: [6, 7]}  # turn: the arms (counting from 0) that rest
    keys = [(0, 0)]
    for turn_no, arms in rests.items():
        runs = []
        for k in arms:
            if runs and k == runs[-1][-1] + 1:
                runs[-1].append(k)
            else:
                runs.append([k])
        for run in runs:
            t0, t1 = arrival(turn_no, run[0]), arrival(turn_no, run[-1])
            keys += [(t0 - 0.12, 0), (t0 - 0.05, REST), (t1 + 0.05, REST), (t1 + 0.12, 0)]
    write_motion(
        OUT / f"{NAME}-motion.json",
        duration=turns * TURN_SECONDS,
        tracks=[
            turn(WHEEL, (0, 1, 0), (0, 0, AXLE_Z), [(0, 0), (turns * TURN_SECONDS, turns * 360)], ease="linear"),
            turn(PLUCKER, (1, 0, 0), (0, SERVO_Y, SERVO_Z), keys),
        ],
        captions=[
            (0, "The wheel turns at a steady speed, locked to the beat. One turn is one bar of eight steps."),
            (1.0, "Each arm is a step. As it passes the bottom, the pick catches its string and plucks it."),
            (2.0, "A felt pad just before the pick quiets each string as it comes round, so a note rings for nearly a whole turn."),
            (4.0, "For a rest, the servo swings the pick clear and the arm goes by in silence. Here it plays every other step."),
            (6.0, "The pattern of rests comes from Control, over the air. The wheel never has to stop."),
        ],
    )


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


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=NAME)
    export_step(model, OUT / f"{NAME}.step")
    export_gltf(model, OUT / f"{NAME}.glb", unit=Unit.MM, binary=True, linear_deflection=0.08, angular_deflection=0.3)
    motion()
    hub_template(OUT / "hub-template.svg")
    shutil.copyfile(__file__, OUT / f"{NAME}-cad.py")

    print(f"motor center {MOTOR_X:.1f}, {MOTOR_Z:.1f}; axle at {AXLE_Z:.1f}; pick at {PICK_Z:.1f}")
    if "--no-render" not in sys.argv:
        render(model, DRAWINGS / f"{NAME}-render.svg")
    print("written to", OUT)


# ---------------------------------------------------------------------------
# Drawings
# ---------------------------------------------------------------------------


def clipped(shapes, keep):
    """Only what lies inside the box keep, solid by solid, for a close-up drawing."""
    kept = []
    for shape in shapes:
        for solid in shape.solids():
            try:
                cut = solid & keep
                kept += cut.solids() if cut is not None else []
            except AssertionError:  # nothing of it inside
                pass
    return Compound(children=kept)


def pick_closeup():
    """The bottom of the wheel, with one arm right at the pick, and the felt damper."""
    shapes = [shape for _, shape, _ in pick_parts() + damper_parts()]
    for shape, _ in arm_parts().values():
        for angle in (0, -45):
            shapes.append(on_arm(shape, angle))
    shapes.append(box(-240, 240, -120, -80, 0, BASE_TOP))
    return clipped(shapes, box(-130, 260, -125, 25, 120, 420))


def drive_closeup():
    """The top of the post from behind: the bearings, the belt and the motor."""
    shapes = [shape for _, shape, _ in stand_parts() + drive_parts() + control_parts()]
    shapes += [shape for name, shape, _ in wheel_parts() if name in ("hub-disc", "flange-coupling", "axle", "axle-pulley", "home-magnet")]
    return clipped(shapes, box(-140, 140, -10, 190, 380, 740))


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(720 * 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(model, path):
    sheet(
        path,
        "Windmill Arpeggio Machine: 3D views",
        "Drawn from the CAD model.",
        [
            (model, (-1300, -3000, 1100), (0, 0, 540), "THE WHOLE ELEMENT, FROM THE FRONT"),
            (pick_closeup(), (420, -650, 560), (20, -50, 260), "THE PICK ON ITS SERVO AT THE BOTTOM, AND THE FELT DAMPER JUST BEFORE IT"),
            (drive_closeup(), (900, 1300, 950), (0, 80, 560), "FROM BEHIND: THE AXLE ON TWO BEARINGS, AND THE MOTOR AND BELT THAT TURN IT"),
        ],
    )


def hub_template(path):
    """Half the hub disc at full size, on a landscape A4 or Letter sheet. Drill one half,
    then turn the sheet about the center hole to drill the other."""
    cx, cy = 148.5, 150.0  # the disc's center on the sheet, in millimeters; the straight edge is a diameter

    def at(r, deg):
        return cx + r * math.cos(math.radians(deg)), cy - r * math.sin(math.radians(deg))

    def hole(r, deg, d):
        x, y = at(r, deg)
        return (
            f'<circle cx="{x:.2f}" cy="{y:.2f}" r="{d / 2}" fill="none" stroke="#111" stroke-width="0.25"/>'
            f'<path d="M{x - d:.2f} {y:.2f}H{x + d:.2f}M{x:.2f} {y - d:.2f}V{y + d:.2f}" stroke="#111" stroke-width="0.15"/>'
        )

    out = []
    x0, x1 = cx - HUB_R, cx + HUB_R
    out.append(f'<path d="M{x0} {cy}A{HUB_R} {HUB_R} 0 0 1 {x1} {cy}Z" fill="none" stroke="#111" stroke-width="0.35"/>')
    out.append(f'<path d="M{cx - CAP_R} {cy}A{CAP_R} {CAP_R} 0 0 1 {cx + CAP_R} {cy}" fill="none" stroke="#888" stroke-width="0.2" stroke-dasharray="1.5 1"/>')
    out.append(hole(0, 0, 10))
    for i in range(ARMS // 2):
        deg = 180 / ARMS + i * 360 / ARMS  # counterclockwise as you face the disc's front
        out.append(
            f'<rect x="{ARM_IN}" y="-10" width="{HUB_R - ARM_IN}" height="20" fill="none" stroke="#888" stroke-width="0.2" '
            f'transform="translate({cx} {cy}) rotate({-deg})"/>'
        )
        for r in (60, 105):
            out.append(hole(r, deg, 5.5))
        lx, ly = at(HUB_R + 6, deg)
        out.append(f'<text x="{lx:.1f}" y="{ly + 1.5:.1f}" font-size="5" text-anchor="middle">{i + 1}</text>')
    for deg in (0, 90, 180):
        out.append(hole(26, deg, 4.5))  # the hub cap's bolts
    for deg in (45, 135):
        out.append(hole(16, deg, 4.5))  # the coupling's bolts: check yours
    notes = [
        "Print at 100%, actual size. The bar below should measure 100 mm.",
        "Lay it on the disc's front face, with the straight edge across the middle. Drill one half, then",
        "turn the sheet half a turn about the center hole and drill the other. Arms 5 to 8 follow 1 to 4.",
        "Drill the center 10 mm and each arm's two holes 5.5 mm. The dashed line is the hub cap's outline;",
        "its holes and the coupling's are 4.5 mm. The coupling's are a common pattern, so check yours.",
        f"The home magnet goes on the back, behind arm 1, {MAGNET_R:.0f} mm from the center.",
    ]
    text = "".join(f'<text x="18" y="{164 + 6 * i}" font-size="4">{line}</text>' for i, line in enumerate(notes))
    path.write_text(
        '<svg xmlns="http://www.w3.org/2000/svg" width="297mm" height="210mm" viewBox="0 0 297 210" '
        "font-family=\"Georgia, 'Times New Roman', serif\" fill=\"#111\">\n"
        "<title>Windmill Arpeggio Machine: hub disc drilling template</title>\n"
        '<rect width="297" height="210" fill="#fff"/>\n'
        '<text x="18" y="14" font-size="5" letter-spacing="1">THE MACHINE · WINDMILL ARPEGGIO MACHINE · HUB DISC, HALF, FULL SIZE</text>\n'
        + "\n".join(out)
        + "\n"
        + text
        + '\n<path d="M18 203H118M18 200V206M118 200V206" stroke="#111" stroke-width="0.35"/>'
        '<text x="122" y="204.5" font-size="4">100 mm</text>\n</svg>\n'
    )


if __name__ == "__main__":
    main()
