"""
Sympathetic Resonance Field, with servos on the chord bars: a variation. The
recipe starts with the autoharp's bars left up and untouched (model.py); here
fifteen servos on a small gantry press them instead. Each bar's felt damps every
string except one chord's, so a breath of wide noise rings that chord, a part
press leaves a muted haze, and two bars with no notes in common silence it.

Run from the cad/ folder:

    uv run sympathetic-resonance-field/servos.py

It writes, for the site:
    site/public/files/sympathetic-resonance-field/stl/servo-rack-{1,2,3}.stl            the printed racks, laid flat
    site/public/files/sympathetic-resonance-field/sympathetic-resonance-field-servos-stl.zip
    site/public/files/sympathetic-resonance-field/sympathetic-resonance-field-servos.step  the whole assembly, for editing
    site/public/files/sympathetic-resonance-field/sympathetic-resonance-field-servos-cad.py this script
    site/src/content/instruments/sympathetic-resonance-field/drawings/sympathetic-resonance-field-servos-render.svg

The autoharp is bought and drawn as a simple shape: a typical 15-bar, 36-string
model. Autoharps differ, above all in where the chord bars' buttons sit, so
measure yours and change BUTTON_ROWS and BAR_X below; the three printed servo
racks follow. Only the racks are printed. Nothing has been built from this yet.

Coordinates, in millimeters: X runs along the strings from the autoharp's
anchor end (0) toward its tuning pins. Y runs across it, from its bass edge (0)
to its treble edge (310). Z is up; the cradle rails' tops are at 0.
"""

from math import cos, radians, sin
from pathlib import Path
import re
import shutil
import sys
import zipfile

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

HERE = Path(__file__).resolve().parent

NAME = "sympathetic-resonance-field"
REPO = HERE.parent.parent
OUT = REPO / f"site/public/files/{NAME}"
DRAWINGS = REPO / f"site/src/content/instruments/{NAME}/drawings"

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

# The autoharp: a typical 15-bar, 36-string model, about 585 x 310 x 45 mm.
OUTLINE = [(0, 0), (585, 0), (585, 90), (330, 310), (0, 310)]  # the head end runs on a slant
PAD = 3.0  # rubber strip on the cradle rails, under the autoharp's back
BODY_H = 45.0
TOP = PAD + BODY_H  # the soundboard's top surface
STRING_Z = TOP + 9.0  # the strings' middle, over the bridges
SOUNDHOLE = (330.0, 120.0, 38.0)

# Strings, lowest at the bass edge. A typical chromatic tuning; yours may differ.
NOTES = (
    "F2 G2 A2 C3 D3 E3 F3 G3 A3 A#3 B3 C4 C#4 D4 D#4 E4 F4 F#4 G4 G#4 A4 A#4 B4 "
    "C5 C#5 D5 D#5 E5 F5 F#5 G5 G#5 A5 A#5 B5 C6"
).split()
STRING_Y = [17 + i * 276 / (len(NOTES) - 1) for i in range(len(NOTES))]
ANCHOR_X, BOTTOM_BRIDGE_X = 12.0, 30.0


def head_bridge_x(y):
    """The head bridge runs on a slant, so the bass strings are long and the treble short."""
    return 548 - (y - 17) * 248 / 276


def drawn(i):
    """Strings are drawn thicker than life, and square, so the viewer shows them; the low wound ones thicker."""
    return 0.75 - 0.3 * i / (len(NOTES) - 1)


# Chord bars, in order from the anchor end. Each bar's felt is notched where its chord's strings pass.
CHORDS = ["Gm", "A7", "Dm", "Bb", "C7", "F", "Cm", "D7", "G7", "C", "Am", "E7", "G", "D", "Em"]
CHORD_TONES = {
    "Gm": "G A# D", "A7": "A C# E G", "Dm": "D F A", "Bb": "A# D F", "C7": "C E G A#", "F": "F A C",
    "Cm": "C D# G", "D7": "D F# A C", "G7": "G B D F", "C": "C E G", "Am": "A C E", "E7": "E G# B D",
    "G": "G B D", "D": "D F# A", "Em": "E G B",
}
BAR_X = [70 + 9.5 * k for k in range(len(CHORDS))]  # each bar's middle
BAR_W, BAR_Y = 7.0, (14.0, 296.0)
GAP = 3.0  # felt to strings, with the bar up
FELT_T, BAR_T = 3.0, 12.0
FELT_Z = STRING_Z + drawn(0) + GAP  # the felt's underside, with the bar up
BUTTON_R, BUTTON_H = 5.0, 7.0
BUTTON_ROWS = (95.0, 150.0, 205.0)  # the buttons sit in three staggered rows across the bars
BUTTON_TOP = FELT_Z + FELT_T + BAR_T + BUTTON_H
HOLDER_X = (BAR_X[0] - 10, BAR_X[-1] + 10)
PRESS = 3.5  # how far a press moves a bar: its felt lands on the strings

# The servos: an MG90S over each button, its shaft along Y, its horn reaching back along X to the button.
REACH = 15.0  # shaft to button, along the strings
SHAFT_Z = BUTTON_TOP + 3.0 + 1.0 + 8.5  # over the button, its rubber bumper and the horn
CASE_LEN, CASE_W = 22.5, 12.2

# The cradle: two 2040 rails lying flat across the autoharp, rubber on top. A gantry of
# 2020 posts and a crossbeam stands on the first, over the chord bars; the racks hang under the beam.
RAILS_X = (130.0, 470.0)  # the rails' middles
RAIL_Y = (-100.0, 450.0)
POST_Y = (-75.0, 365.0)  # each post's low side
BEAM_Z = 120.0  # the crossbeam's underside, the posts' tops
TRANSDUCER = (300.0, 140.0)
FEET_Y = (-85.0, 435.0)


# ---------------------------------------------------------------------------
# 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 span(p, q):
    return box(min(p[0], q[0]), max(p[0], q[0]), min(p[1], q[1]), max(p[1], q[1]), min(p[2], q[2]), max(p[2], q[2]))


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 one 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 * Pos(0, 0, length / 2) * Box(2 * half, 2 * half, length)


def flat_xz(p0, p1, wide, thick):
    """A flat bar from p0 to p1 in a plane along the strings (no Y in p1 - p0), wide along Y."""
    d = [b - a for a, b in zip(p0, p1)]
    length = sum(c * c for c in d) ** 0.5
    return Plane(origin=p0, x_dir=(0, 1, 0), z_dir=d).location * Pos(0, 0, length / 2) * Box(wide, thick, length)


def vslot(x0, x1, y0, y1, z0, z1):
    """V-slot extrusion, simplified: a groove down the middle of each 20 mm of every long face, and a bore down
    each 20 mm square. It runs along its longest side."""
    lo, hi = (x0, y0, z0), (x1, y1, z1)
    size = [b - a for a, b in zip(lo, hi)]
    along = size.index(max(size))
    a, b = [i for i in range(3) if i != along]
    shape = box(x0, x1, y0, y1, z0, z1)
    for face, other in ((a, b), (b, a)):
        for k in range(round(size[other] / 20)):
            c = lo[other] + 10 + 20 * k
            for side, depth in ((lo[face], 6), (hi[face], -6)):
                r = [[lo[i], hi[i]] for i in range(3)]
                r[other] = [c - 3.1, c + 3.1]
                r[face] = sorted((side, side + depth))
                shape -= box(*r[0], *r[1], *r[2])
    for i in range(round(size[a] / 20)):
        for j in range(round(size[b] / 20)):
            c = [0.0, 0.0, 0.0]
            c[a], c[b] = lo[a] + 10 + 20 * i, lo[b] + 10 + 20 * j
            if along == 0:
                shape -= cyl_x(c[1], c[2], 2.1, x0, x1)
            elif along == 1:
                shape -= cyl_y(c[0], c[2], 2.1, y0, y1)
            else:
                shape -= cyl_z(c[0], c[1], 2.1, z0, z1)
    return shape


def bracket(c, d1, d2, w):
    """A 20 mm corner bracket in an inside corner at c. d1 and d2 point along its two legs, away from the corner;
    each leg lies flat on the face the other leg stands off. w is the axis of its width."""

    def at(s, t, r):
        return tuple(c[i] + s * d1[i] + t * d2[i] + r * w[i] for i in range(3))

    return span(at(0, 0, -10), at(20, 3, 10)) + span(at(0, 0, -10), at(3, 20, 10))


def pitch_class(note):
    return note.rstrip("0123456789")


def button(k):
    """Where bar k's button sits: (x, y)."""
    return BAR_X[k], BUTTON_ROWS[k % len(BUTTON_ROWS)]


def shaft(k):
    x, y = button(k)
    return x + REACH, y, SHAFT_Z


def outline_y_max(x):
    """How far across the autoharp reaches at x."""
    (x2, y2), (x3, y3) = OUTLINE[2], OUTLINE[3]
    if x <= x3:
        return y3
    return y2 + (x2 - x) * (y3 - y2) / (x2 - x3)


# ---------------------------------------------------------------------------
# The autoharp, bought, drawn simply
# ---------------------------------------------------------------------------


def autoharp():
    body = Pos(0, 0, PAD) * extrude(Polygon(*OUTLINE, align=None), amount=BODY_H)
    x, y, r = SOUNDHOLE
    body -= cyl_z(x, y, r, TOP - 25, TOP + 1)

    bottom_bridge = box(BOTTOM_BRIDGE_X - 4, BOTTOM_BRIDGE_X + 4, 8, 302, TOP, STRING_Z - 0.5)
    y0, y1 = 8.0, 302.0
    p0, p1 = (head_bridge_x(y0), y0, TOP), (head_bridge_x(y1), y1, TOP)
    d = (p1[0] - p0[0], p1[1] - p0[1], 0)
    length = (d[0] ** 2 + d[1] ** 2) ** 0.5
    head_bridge = Plane(origin=p0, x_dir=(d[1] / length, -d[0] / length, 0), z_dir=d).location * Pos(0, (STRING_Z - 0.5 - TOP) / 2, length / 2) * Box(6, STRING_Z - 0.5 - TOP, length)

    pins, anchors, strings = [], [], []
    for i, y in enumerate(STRING_Y):
        pin_x = head_bridge_x(y) + 18 + 8 * (i % 2)
        pins.append(cyl_z(pin_x, y, 3.2, TOP, TOP + 14) + cyl_z(pin_x, y, 2.2, TOP + 14, TOP + 17))
        anchors.append(cyl_z(ANCHOR_X, y, 1.6, TOP, TOP + 6))
        path = [(ANCHOR_X, y, TOP + 4), (BOTTOM_BRIDGE_X, y, STRING_Z), (head_bridge_x(y), y, STRING_Z), (pin_x, y, TOP + 11)]
        strings.append(Compound(children=[strand(a, b, drawn(i)) for a, b in zip(path, path[1:])]))

    holder = box(*HOLDER_X, 0, BAR_Y[0], TOP, BUTTON_TOP - BUTTON_H + 1) + box(*HOLDER_X, BAR_Y[1], 310, TOP, BUTTON_TOP - BUTTON_H + 1)
    return [
        ("autoharp-body", body, (0.55, 0.36, 0.2)),
        ("bridges", bottom_bridge + head_bridge, (0.3, 0.2, 0.12)),
        ("tuning-pins", Compound(children=pins), (0.72, 0.72, 0.72)),
        ("anchor-pins", Compound(children=anchors), (0.72, 0.72, 0.72)),
        ("strings", Compound(children=strings), (0.85, 0.7, 0.3)),
        ("bar-holder", holder, (0.2, 0.2, 0.22)),
    ]


def chord_bar(k):
    """One chord bar, up: the bar, its felt notched under its chord's strings, and its button."""
    x = BAR_X[k]
    felt = box(x - BAR_W / 2, x + BAR_W / 2, *BAR_Y, FELT_Z, FELT_Z + FELT_T)
    tones = set(CHORD_TONES[CHORDS[k]].split())
    for y, note in zip(STRING_Y, NOTES):
        if pitch_class(note) in tones:
            felt -= box(x - BAR_W, x + BAR_W, y - 2.6, y + 2.6, FELT_Z - 1, FELT_Z + FELT_T - 0.6)
    bar = box(x - BAR_W / 2, x + BAR_W / 2, *BAR_Y, FELT_Z + FELT_T, FELT_Z + FELT_T + BAR_T)
    bx, by = button(k)
    bar += cyl_z(bx, by, BUTTON_R, FELT_Z + FELT_T + BAR_T, BUTTON_TOP)
    return bar + felt


# ---------------------------------------------------------------------------
# The servos and their racks
# ---------------------------------------------------------------------------


def servo(k):
    """An MG90S with its shaft along +Y, the horn on the button side. Returns the case and the horn."""
    sx, sy, sz = shaft(k)
    top = sy + 2.5  # the case's shaft face
    case = box(sx - CASE_W / 2, sx + CASE_W / 2, top, top + CASE_LEN, sz - 5.9, sz + 16.9)
    case += box(sx - CASE_W / 2, sx + CASE_W / 2, top + 4.3, top + 6.8, sz + 5.5 - 16.25, sz + 5.5 + 16.25)  # tabs
    case += cyl_y(sx, sz, 2.4, sy + 1, top)  # the gear boss and shaft
    bx, by = button(k)
    tip = (bx, by, BUTTON_TOP + 3.0 + 1.0)
    horn = cyl_y(sx, sz, 3.5, sy - 1, sy + 1) + flat_xz((sx, sy, sz), tip, 2.0, 4.0)
    horn += cyl_z(bx, by, 4.0, BUTTON_TOP, BUTTON_TOP + 3.0)  # a stick-on rubber bumper, pressing the button
    return case, horn


RACK_T = 4.0


def rack_y(row):
    """The rack's front face, against the servos' tabs."""
    return BUTTON_ROWS[row] + 2.5 + 6.8


def servo_rack(row):
    """A plate with a window for each servo in one row of buttons; the servos' tabs screw to its front.
    A flange along its top bolts under the crossbeam with two M5 screws."""
    ks = [k for k in range(len(CHORDS)) if k % len(BUTTON_ROWS) == row]
    xs = [shaft(k)[0] for k in ks]
    x0, x1 = min(xs) - 18, max(xs) + 18
    y0 = rack_y(row)
    z0 = SHAFT_Z - 12
    plate = box(x0, x1, y0, y0 + RACK_T, z0, BEAM_Z)
    flange = box(x0, x1, y0, y0 + 30, BEAM_Z - RACK_T, BEAM_Z)
    rib = Compound(children=[box(x - 1.5, x + 1.5, y0, y0 + 30, BEAM_Z - 12, BEAM_Z) for x in (x0 + 1.5, x1 - 1.5)])
    rack = plate + flange + rib
    for x in xs:
        rack -= box(x - CASE_W / 2 - 0.3, x + CASE_W / 2 + 0.3, y0 - 1, y0 + RACK_T + 1, SHAFT_Z - 6.2, SHAFT_Z + 17.2)
        for z in (SHAFT_Z - 5.9 - 2.4, SHAFT_Z + 16.9 + 2.4):
            rack -= cyl_y(x, z, 0.9, y0 - 1, y0 + RACK_T + 1)  # M2 self-tapping screws, as sold with the servos
    for y in (y0 + 10, y0 + 22):
        rack -= cyl_z(RAILS_X[0], y, 2.75, BEAM_Z - RACK_T - 1, BEAM_Z + 1)
    return rack


# (name, builder, rotation that lays it flat for printing, what it is)
PRINTED = [
    (f"servo-rack-{r + 1}", (lambda r=r: servo_rack(r)), Rot(-90, 0, 0), f"Servo rack {r + 1}: holds the servos for one row of buttons")
    for r in range(len(BUTTON_ROWS))
]


# ---------------------------------------------------------------------------
# The cradle, the gantry and the electronics
# ---------------------------------------------------------------------------


def cradle():
    rails = Compound(children=[vslot(x - 20, x + 20, *RAIL_Y, -20, 0) for x in RAILS_X])
    pads = Compound(children=[box(x - 20, x + 20, 0, outline_y_max(x + 20), 0, PAD) for x in RAILS_X])
    stops, stop_pads = [], []
    for x, y_edge, dy in ((RAILS_X[0], 0, -1), (RAILS_X[0], 310, 1), (RAILS_X[1], 0, -1)):
        stops.append(bracket((x, y_edge + 3 * dy, 0), (0, dy, 0), (0, 0, 1), (1, 0, 0)))
        stop_pads.append(box(x - 10, x + 10, *sorted((y_edge, y_edge + 3 * dy)), PAD, PAD + 18))
    feet = Compound(children=[cyl_z(x, y, 10, -35, -20) + cyl_z(x, y, 6, -37, -35) for x in RAILS_X for y in FEET_Y])
    return [
        ("cradle-rails", rails, (0.78, 0.8, 0.83)),
        ("rubber-pads", pads + Compound(children=stop_pads), (0.15, 0.15, 0.15)),
        ("edge-stops", Compound(children=stops), (0.55, 0.57, 0.6)),
        ("feet", feet, (0.15, 0.15, 0.15)),
    ]


def gantry():
    x = RAILS_X[0]
    posts = [vslot(x - 10, x + 10, y, y + 20, 0, BEAM_Z) for y in POST_Y]
    beam = vslot(x - 10, x + 10, POST_Y[0], POST_Y[1] + 20, BEAM_Z, BEAM_Z + 20)
    brackets = []
    for y in POST_Y:
        brackets.append(bracket((x, y, 0), (0, -1, 0), (0, 0, 1), (1, 0, 0)))
        brackets.append(bracket((x, y + 20, 0), (0, 1, 0), (0, 0, 1), (1, 0, 0)))
    brackets.append(bracket((x, POST_Y[0] + 20, BEAM_Z), (0, 1, 0), (0, 0, -1), (1, 0, 0)))
    brackets.append(bracket((x, POST_Y[1], BEAM_Z), (0, -1, 0), (0, 0, -1), (1, 0, 0)))
    board = box(x - 12.5, x + 12.5, 150 - 31, 150 + 31, BEAM_Z + 25, BEAM_Z + 26.6)
    board += Compound(children=[cyl_z(x - 9, 150 + s * 27.5, 1.6, BEAM_Z + 20, BEAM_Z + 25) for s in (-1, 1)])
    board += box(x - 9, x - 3, 150 - 24, 150 + 24, BEAM_Z + 26.6, BEAM_Z + 34)  # the row of servo headers
    return [
        ("posts", Compound(children=posts), (0.78, 0.8, 0.83)),
        ("crossbeam", beam, (0.78, 0.8, 0.83)),
        ("post-brackets", Compound(children=brackets), (0.55, 0.57, 0.6)),
        ("servo-board", board, (0.15, 0.35, 0.25)),
    ]


def electronics():
    """On the second cradle rail, beyond the autoharp: the battery at the front, the box and the stop button at the back."""
    x = RAILS_X[1]
    t = cyl_z(*TRANSDUCER, 16, PAD - 18, PAD) + cyl_z(*TRANSDUCER, 11, PAD - 23, PAD - 18)
    t += box(TRANSDUCER[0] - 23, TRANSDUCER[0] + 23, TRANSDUCER[1] - 5, TRANSDUCER[1] + 5, PAD - 3, PAD)  # its screw ears
    return [
        ("transducer", t, (0.2, 0.2, 0.22)),
        ("electronics-box", box(x - 40, x + 40, 230, 390, 0, 60) + cyl_x(360, 30, 5, x + 40, x + 46), (0.3, 0.3, 0.32)),
        ("stop-button", box(x - 25, x + 25, 395, 445, 0, 50) + cyl_z(x, 420, 14, 50, 60) + cyl_z(x, 420, 9, 60, 64), (0.85, 0.7, 0.2)),
        ("battery-adapter", box(x - 20, x + 20, -95, -15, 0, 22), (0.3, 0.3, 0.32)),
        ("battery", box(x - 35, x + 35, -100, -10, 22, 80), (0.6, 0.2, 0.15)),
    ]


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


def press_angle(depth):
    """Degrees about +Y that lower the horn's tip by depth: negative swings it down."""
    sx, _, sz = shaft(0)
    bx, _ = button(0)
    x, z = bx - sx, BUTTON_TOP + 4.0 - sz
    a = 0.0
    while True:
        t = radians(a)
        if z - (-x * sin(t) + z * cos(t)) >= depth:
            return a
        a -= 0.1


C, F, G7, BB = (CHORDS.index(c) for c in ("C", "F", "G7", "Bb"))


# ---------------------------------------------------------------------------
# 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 report():
    k = C
    free = [n for n in NOTES if pitch_class(n) in CHORD_TONES["C"].split()]
    print(f"the C bar leaves {len(free)} strings free: {' '.join(free)}")
    print(f"a full press turns each horn {press_angle(PRESS):.1f} degrees")
    sx, sy, sz = shaft(k)
    print(f"servo shaft {sz:.1f} mm up; buttons' tops at {BUTTON_TOP:.1f}; bars' tops at {FELT_Z + FELT_T + BAR_T:.1f}")
    print(f"racks clear the bars by {SHAFT_Z - 12 - (FELT_Z + FELT_T + BAR_T):.1f} mm")
    print(f"each rack's flange ends {BUTTON_ROWS[1] - 1 - (rack_y(0) + 30):.1f} mm short of the next row's horns")
    print(f"the transducer is {min(abs(TRANSDUCER[0] - x) for x in RAILS_X) - 20 - 23:.0f} mm clear of the nearest cradle rail")


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

    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)
    with zipfile.ZipFile(OUT / f"{NAME}-servos-stl.zip", "w", zipfile.ZIP_DEFLATED) as z:
        for name, *_ in PRINTED:
            z.write(OUT / "stl" / f"{name}.stl", f"{name}.stl")

    parts = autoharp()
    parts += [(f"chord-bar-{k + 1}", chord_bar(k), (0.25, 0.2, 0.15)) for k in range(len(CHORDS))]
    for k in range(len(CHORDS)):
        case, horn = servo(k)
        parts += [(f"servo-{k + 1}", case, (0.25, 0.3, 0.45)), (f"servo-horn-{k + 1}", horn, (0.9, 0.9, 0.88))]
    parts += cradle() + gantry() + electronics()
    for name, shape, rgb in parts:
        shape.label = f"bought:{name}"
        shape.color = Color(*rgb)
        assembly.append(shape)

    model = Compound(children=assembly, label=f"{NAME}-servos")
    export_step(model, OUT / f"{NAME}-servos.step")
    shutil.copyfile(__file__, OUT / f"{NAME}-servos-cad.py")
    render(DRAWINGS / f"{NAME}-servos-render.svg", model)
    print("written to", OUT)


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


def bar_closeup():
    """The C bar lifted clear of its strings, to show the felt notched where the C, E and G strings pass."""
    k = C
    lift = 22.0
    shapes = [Pos(0, 0, lift) * chord_bar(k)]
    x = BAR_X[k]
    for i, y in enumerate(STRING_Y):
        shapes.append(box(x - 30, x + 30, y - drawn(i), y + drawn(i), STRING_Z - drawn(i), STRING_Z + drawn(i)))
    shapes.append(box(x - 30, x + 30, 0, 310, TOP - 4, TOP))
    return Compound(children=shapes)


def rack_closeup():
    """Three servos in one rack, pressing their buttons."""
    shapes = [servo_rack(0)]
    y = BUTTON_ROWS[0]
    for k in range(0, len(CHORDS), len(BUTTON_ROWS)):
        case, horn = servo(k)
        shapes += [case, horn, chord_bar(k) & box(0, 300, y - 25, y + 45, 0, 200)]
    return Compound(children=shapes)


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, model):
    x = BAR_X[C]
    sheet(
        path,
        "Sympathetic Resonance Field: servos on the chord bars",
        "Drawn from the CAD model.",
        [
            (model, (-380, -620, 560), (300, 160, 40), "AN AUTOHARP IN A V-SLOT CRADLE, WITH A SERVO OVER EACH CHORD BAR"),
            (bar_closeup(), (x - 260, -160, 20), (x, 150, STRING_Z + 10), "THE C BAR, LIFTED: ITS FELT IS CUT AWAY OVER EVERY C, E AND G STRING"),
            (rack_closeup(), (BAR_X[6] - 120, BUTTON_ROWS[0] - 170, 170), (BAR_X[6] + 15, BUTTON_ROWS[0] + 10, 90), "ONE PRINTED RACK HOLDS A ROW OF SERVOS OVER THEIR BUTTONS"),
        ],
    )


if __name__ == "__main__":
    main()
