"""
Sympathetic Resonance Field, the other body: eight guitar strings over a plywood
soundboard on a V-slot frame, built instead of bought. The recipe starts with an
autoharp (model.py); this is the option for builders who want to free any set of
strings, one damper per string. A surface transducer under the bridge shakes the
board with noise the ESP32 makes, and every string whose damper is lifted picks
its own note out of the noise, so the freed strings are the chord.

Run from the cad/ folder:

    uv run sympathetic-resonance-field/frame.py

It writes, for the site:
    site/public/files/sympathetic-resonance-field/sympathetic-resonance-field-frame.step     the whole frame, for editing
    site/public/files/sympathetic-resonance-field/sympathetic-resonance-field-frame-cad.py   this script
    site/src/content/instruments/sympathetic-resonance-field/drawings/sympathetic-resonance-field-frame-render.svg

Nothing is printed. The frame is bought V-slot; the soundboard, the three
hardwood blocks and the two aluminum pieces are cut and drilled; 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.

The strings' pull, about 730 N in all, is carried by the frame: the tuners and
the hitch block sit on the end bars, and the long rails butt against them. The
soundboard only carries the bridge, which the strings press down on because
they angle down a few degrees on their way to the hitch.

Coordinates, in millimeters: X runs along the strings from the tuner end of the
frame (0) to the hitch end (600). Y runs across it, front (0) to back (260). Z is
up; the frame stands from 0 to 40 on rubber feet.
"""

from math import atan2, cos, degrees, hypot, radians, sin
from pathlib import Path
import re
import shutil
import sys

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

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

L, W = 600.0, 260.0  # the frame: 20 x 40 mm V-slot standing on edge, cross bars full width at each end
H = 40.0  # the frame's top
BOARD_T = 6.0  # birch plywood, 4 to 6 mm
BOARD_TOP = H + BOARD_T
BOARD_END = L - 22.0  # the board stops just short of the hitch-end cross bar, so the hitch block sits on the bar

# The bridge, the nut block and the hitch block are all cut from one hardwood
# 1x2 (about 19 x 38 mm). The bridge and the nut lie flat, each with a 6 mm
# steel rod in a shallow groove along its top; the hitch block stands on edge.
STRIP_W, STRIP_T = 38.0, 19.0
ROD_R, GROOVE = 3.0, 2.0
ROD_Z = BOARD_TOP + STRIP_T - GROOVE + ROD_R
ROD_TOP = ROD_Z + ROD_R
ROD_Y = (30.0, 230.0)  # 200 mm rods

SCALE = 520.0  # nut rod to bridge rod, the same as Lapless Steel
NUT_X = 6.0  # the nut rod, near the outer edge of its block, over the tuner-end cross bar
BRIDGE_X = NUT_X + SCALE
NUT_CHAMFER = 3.0  # a small slope planed on the nut block's outer top edge, so the strings clear it

PITCH = 25.0
# (note, Hz, gauge in inches, wound, D'Addario unit weight in lb/in). A tension chart gives these.
STRINGS = [
    ("D3", 146.83, 0.036, True, 0.00024616),  # Lapless Steel's string
    ("F3", 174.61, 0.028, True, 0.00014778),
    ("G3", 196.00, 0.026, True, 0.00012671),
    ("A3", 220.00, 0.020, False, 0.00008861),
    ("C4", 261.63, 0.017, False, 0.00006402),
    ("D4", 293.66, 0.016, False, 0.00005671),
    ("F4", 349.23, 0.013, False, 0.00003744),
    ("G4", 392.00, 0.012, False, 0.00003190),
]
STRING_Y = [W / 2 + (i - 3.5) * PITCH for i in range(len(STRINGS))]  # lowest at the front


def radius(gauge):
    return gauge * 25.4 / 2


def drawn(gauge):
    """Strings are drawn thicker than life, and square, so the viewer shows them; the low ones thicker."""
    return 0.45 + radius(gauge)


# Tuners: sealed guitar tuners, as on Lapless Steel, posts up through a drilled
# 40 x 40 x 3 aluminum angle on the tuner-end cross bar's outer face.
POST_X = -26.0
POST_R = 3.0
WRAP_Z = 56.0  # wind each string so it leaves near the top of its post
ANGLE_TOP = H + 3.0

# The hitch block: the strings come through it from the outside, each ball end
# on a washer on its outer face. The holes sit a few millimeters below the
# bridge's rod, so the strings angle down a few degrees and press the bridge
# onto the board.
HITCH_X = (L - 20.0, L - 1.0)
HITCH_Z = 65.0

# The transducer: a 32 mm surface transducer glued under the board, right below the middle of the bridge.
TRANSDUCER = (BRIDGE_X, W / 2)

# The dampers: a felt pad on each servo's horn, held down on its string by a
# small spring. The felt touches an eighth of the way from the nut; the whole
# damper rail slides along the frame to try other places.
TOUCH = NUT_X + SCALE / 8
SERVO_DY = PITCH / 2  # each servo hangs between its string and the next
SHAFT_Z = 90.0
LIFT = -25.0  # degrees about +X: the felt swings up and off its string
FELT = (10.0, 9.0, 5.0)  # along the string, across it, thick
SPRING_DY = 7.5  # the spring hangs beside the servo's case, from a short second arm on the horn

# The MG90S, with its shaft along X toward the nut: the case runs back from the horn.
CASE_X0 = TOUCH + 2.5
CASE_LEN, CASE_W = 22.5, 12.2
TAB_X = (CASE_X0 + 4.3, CASE_X0 + 6.8)
# A 40 x 3 aluminum flat bar carries the servos by their tabs and bolts to the front of a 2020 rail.
BAR_X = (TAB_X[1], TAB_X[1] + 3.0)
BAR_Z = (77.0, 117.0)
RAIL_X = (BAR_X[1], BAR_X[1] + 20.0)
RAIL_Z = (BAR_Z[1] - 20.0, BAR_Z[1])
RAIL_Y = (-20.0, W + 20.0)

FOOT_X = (40.0, L - 40.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(p0, p1, wide, thick):
    """A flat bar from p0 to p1 in a plane across the strings (no X in p1 - p0), wide along X."""
    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=(1, 0, 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 string_top(i):
    return ROD_TOP + 2 * radius(STRINGS[i][2])


def string_path(i):
    """From the side of the tuner post, over the nut rod and the bridge rod, down to the hitch block and through it."""
    y = STRING_Y[i]
    z = ROD_TOP + radius(STRINGS[i][2])
    return [(POST_X + POST_R, y, WRAP_Z), (NUT_X, y, z), (BRIDGE_X, y, z), (HITCH_X[0], y, HITCH_Z), (HITCH_X[1] + 1, y, HITCH_Z)]


def tension(i):
    """Newtons, from the note, the speaking length and the string's weight per length."""
    _, hz, _, _, lb_per_in = STRINGS[i]
    return (2 * SCALE / 1000 * hz) ** 2 * lb_per_in * 17.858


# ---------------------------------------------------------------------------
# The parts, in assembly position
# ---------------------------------------------------------------------------


def frame():
    rails = Compound(children=[vslot(20, L - 20, y, y + 20, 0, H) for y in (0, W - 20)])
    ends = Compound(children=[vslot(x, x + 20, 0, W, 0, H) for x in (0, L - 20)])
    corners = []
    for x, dx in ((20, 1), (L - 20, -1)):
        for y, dy in ((20, 1), (W - 20, -1)):
            for z in (10, 30):
                corners.append(bracket((x, y, z), (dx, 0, 0), (0, dy, 0), (0, 0, 1)))
    feet = Compound(children=[cyl_z(x, y, 10, -15, 0) + cyl_z(x, y, 6, -17, -15) for x in FOOT_X for y in (10, W - 10)])
    return [("long-rails", rails), ("end-bars", ends), ("corner-brackets", Compound(children=corners)), ("feet", feet)]


def soundboard():
    return box(0, BOARD_END, 0, W, H, BOARD_TOP)


def strip(x0, x1, y0, y1, z0, z1, groove_x=None):
    s = box(x0, x1, y0, y1, z0, z1)
    if groove_x is not None:
        s -= cyl_y(groove_x, ROD_Z, ROD_R + 0.1, y0 - 1, y1 + 1)
    return s


def bridge():
    return strip(BRIDGE_X - STRIP_W / 2, BRIDGE_X + STRIP_W / 2, ROD_Y[0], ROD_Y[1], BOARD_TOP, BOARD_TOP + STRIP_T, BRIDGE_X)


def nut_block():
    b = strip(0, STRIP_W, 20, W - 20, BOARD_TOP, BOARD_TOP + STRIP_T, NUT_X)
    top = BOARD_TOP + STRIP_T
    b -= Pos(0, W / 2, top) * Rot(0, 45, 0) * Box(NUT_CHAMFER * 2**0.5, W, NUT_CHAMFER * 2**0.5)
    return b


def hitch_block():
    b = box(*HITCH_X, 20, W - 20, H, H + STRIP_W)
    for y in STRING_Y:
        b -= cyl_x(y, HITCH_Z, 1.0, HITCH_X[0] - 1, HITCH_X[1] + 1)
    return b


def ball_ends():
    return Compound(
        children=[cyl_x(y, HITCH_Z, 4, HITCH_X[1], HITCH_X[1] + 1) + cyl_x(y, HITCH_Z, 2.2, HITCH_X[1] + 1, HITCH_X[1] + 4) for y in STRING_Y]
    )


def tuner_parts():
    angle = box(-43, 0, 20, W - 20, H, ANGLE_TOP) + box(-3, 0, 20, W - 20, 3, ANGLE_TOP)
    tuners = []
    for y in STRING_Y:
        angle -= cyl_z(POST_X, y, 5, H - 1, ANGLE_TOP + 1)
        t = cyl_z(POST_X, y, POST_R, ANGLE_TOP, ANGLE_TOP + 21) + cyl_z(POST_X, y, 5, ANGLE_TOP, ANGLE_TOP + 3)
        t += cyl_z(POST_X, y, POST_R + 0.8, WRAP_Z - 3, WRAP_Z)  # the string's winds
        t += box(POST_X - 13, POST_X + 6, y - 8.5, y + 8.5, H - 14, H)  # the sealed gear case, under the angle
        t += cyl_x(y, H - 7, 2, POST_X - 23, POST_X - 13) + box(POST_X - 31, POST_X - 23, y - 5, y + 5, H - 17, H + 3)  # the key
        tuners.append(t)
    return [("tuner-angle", angle), ("tuners", Compound(children=tuners))]


def rods():
    return [("nut-rod", cyl_y(NUT_X, ROD_Z, ROD_R, *ROD_Y)), ("bridge-rod", cyl_y(BRIDGE_X, ROD_Z, ROD_R, *ROD_Y))]


def felt_mute():
    """A felt strip woven through the strings between the bridge and the hitch, so that short length stays quiet."""
    x = (BRIDGE_X + STRIP_W / 2 + HITCH_X[0]) / 2
    z = ROD_TOP - (ROD_TOP - HITCH_Z) * (x - BRIDGE_X) / (HITCH_X[0] - BRIDGE_X)
    return box(x - 4, x + 4, ROD_Y[0], ROD_Y[1], z - 5.5, z - 0.6)


def transducer():
    x, y = TRANSDUCER
    t = cyl_z(x, y, 16, H - 16, H) + cyl_z(x, y, 11, H - 21, H - 16)
    t += box(x - 23, x + 23, y - 5, y + 5, H - 3, H)  # its screw ears, under the board
    return t


def damper(i):
    """One damper: the servo, and the horn with its felt and its short spring arm, in the damped position."""
    y_s = STRING_Y[i] + SERVO_DY
    hub = (TOUCH, y_s, SHAFT_Z)
    felt_bottom = ROD_TOP + radius(STRINGS[i][2]) + drawn(STRINGS[i][2])
    felt = box(TOUCH - FELT[0] / 2, TOUCH + FELT[0] / 2, STRING_Y[i] - FELT[1] / 2, STRING_Y[i] + FELT[1] / 2, felt_bottom, felt_bottom + FELT[2])
    tip = (TOUCH, STRING_Y[i], felt_bottom + FELT[2] + 0.8)
    spring_low = (TOUCH, y_s + SPRING_DY, SHAFT_Z + 5.0)
    arm = flat(hub, tip, 4.0, 1.6) + flat(hub, spring_low, 4.0, 1.6) + cyl_x(y_s, SHAFT_Z, 3.5, TOUCH - 1, TOUCH + 1)
    arm += felt

    x0 = CASE_X0
    case = box(x0, x0 + CASE_LEN, y_s - CASE_W / 2, y_s + CASE_W / 2, SHAFT_Z - 5.9, SHAFT_Z + 16.9)
    case += box(*TAB_X, y_s - CASE_W / 2, y_s + CASE_W / 2, SHAFT_Z + 5.5 - 16.25, SHAFT_Z + 5.5 + 16.25)  # tabs
    case += cyl_x(y_s, SHAFT_Z, 2.4, TOUCH + 1, x0)  # the gear boss and shaft

    anchor_z = BAR_Z[1] - 4
    spring = strand(spring_low, (TOUCH, y_s + SPRING_DY, anchor_z), 0.9)
    anchor = cyl_x(y_s + SPRING_DY, anchor_z, 1.5, TOUCH - 2, BAR_X[0])  # an M3 screw out of the bar's front face
    return case, arm, spring, anchor


def damper_rail():
    """Two 2020 posts on the long rails' outer faces, a 2020 rail across their tops, and the flat bar the servos hang from."""
    px = RAIL_X
    posts = [vslot(*px, -20, 0, 0, RAIL_Z[0]), vslot(*px, W, W + 20, 0, RAIL_Z[0])]
    rail = vslot(*px, *RAIL_Y, *RAIL_Z)
    bar = box(*BAR_X, *RAIL_Y, *BAR_Z)
    for y in STRING_Y:  # each servo's body passes through a window, and its tabs screw on with M2 screws either side
        y_s = y + SERVO_DY
        bar -= box(BAR_X[0] - 1, BAR_X[1] + 1, y_s - CASE_W / 2 - 0.3, y_s + CASE_W / 2 + 0.3, SHAFT_Z - 6.2, SHAFT_Z + 17.2)
        for z in (SHAFT_Z + 5.5 - 14, SHAFT_Z + 5.5 + 14):
            bar -= cyl_x(y_s, z, 1.1, BAR_X[0] - 1, BAR_X[1] + 1)
    brackets = []
    for y_face, dy in ((0, -1), (W, 1)):
        brackets.append(bracket((px[0], y_face, 30), (-1, 0, 0), (0, dy, 0), (0, 0, 1)))
        brackets.append(bracket((px[1], y_face, 30), (1, 0, 0), (0, dy, 0), (0, 0, 1)))
        brackets.append(bracket(((px[0] + px[1]) / 2, y_face, RAIL_Z[0]), (0, -dy, 0), (0, 0, -1), (1, 0, 0)))
    board = box(px[0] - 2.5, px[1] + 2.5, W / 2 - 31, W / 2 + 31, RAIL_Z[1] + 5, RAIL_Z[1] + 6.6)
    board += Compound(children=[cyl_z(px[0] + 1, W / 2 + s * 27.5, 1.6, RAIL_Z[1], RAIL_Z[1] + 5) for s in (-1, 1)])
    board += box(px[0] + 3, px[0] + 9, W / 2 - 24, W / 2 + 24, RAIL_Z[1] + 6.6, RAIL_Z[1] + 14)  # the row of servo headers
    return [
        ("damper-posts", Compound(children=posts)),
        ("damper-rail", rail),
        ("damper-bar", bar),
        ("damper-brackets", Compound(children=brackets)),
        ("servo-board", board),
    ]


def electronics():
    """On the long rails' outer faces: the box at the back, the stop button at the front, the battery at the back."""
    ebox = box(150, 310, W, W + 55, -12, 58) + cyl_y(320 - 10, 20, 5, W + 55, W + 61)
    stop = box(500, 550, -52, 0, -12, 38) + cyl_z(525, -26, 14, 38, 48) + cyl_z(525, -26, 9, 48, 52)
    return [
        ("electronics-box", ebox),
        ("stop-button", stop),
        ("battery-adapter", box(470, 550, W, W + 20, 2, 32)),
        ("battery", box(460, 560, W + 21, W + 91, -13, 45)),
    ]


# ---------------------------------------------------------------------------
# The assembly
# ---------------------------------------------------------------------------

ALU, STEEL, DARK = (0.78, 0.8, 0.83), (0.72, 0.73, 0.75), (0.2, 0.2, 0.22)
PLY, HARDWOOD, FELT_C = (0.86, 0.74, 0.55), (0.62, 0.43, 0.26), (0.34, 0.3, 0.28)


def parts():
    out = [(n, s, ALU if n != "feet" and n != "corner-brackets" else DARK if n == "feet" else (0.55, 0.57, 0.6)) for n, s in frame()]
    out += [
        ("soundboard", soundboard(), PLY),
        ("bridge", bridge(), HARDWOOD),
        ("nut-block", nut_block(), HARDWOOD),
        ("hitch-block", hitch_block(), HARDWOOD),
        ("ball-ends", ball_ends(), (0.72, 0.62, 0.35)),
        ("felt-mute", felt_mute(), FELT_C),
        ("transducer", transducer(), DARK),
    ]
    out += [(n, s, ALU if n == "tuner-angle" else (0.72, 0.72, 0.72)) for n, s in tuner_parts()]
    out += [(n, s, STEEL) for n, s in rods()]
    for n, s in damper_rail():
        out.append((n, s, (0.15, 0.35, 0.25) if n == "servo-board" else (0.55, 0.57, 0.6) if n == "damper-brackets" else ALU))
    for i in range(len(STRINGS)):
        case, arm, spring, anchor = damper(i)
        out += [
            (f"servo-{i + 1}", case, (0.25, 0.3, 0.45)),
            (f"damper-arm-{i + 1}", arm, (0.9, 0.9, 0.88)),
            (f"spring-{i + 1}", spring + anchor, STEEL),
        ]
    out += [(n, s, DARK) for n, s in electronics()]
    for i, (note, *_rest) in enumerate(STRINGS):
        path = string_path(i)
        half = drawn(STRINGS[i][2])
        out.append((f"string-{i + 1}-{note.lower()}", Compound(children=[strand(a, b, half) for a, b in zip(path, path[1:])]), (0.85, 0.7, 0.3)))
    return out


def report():
    print(f"{'string':8} {'gauge':>7} {'pull N':>7}")
    for i, (note, _, gauge, wound, _) in enumerate(STRINGS):
        print(f"{note:8} {gauge:>6.3f}{'w' if wound else ' '} {tension(i):7.0f}")
    total = sum(tension(i) for i in range(len(STRINGS)))
    down = degrees(atan2(ROD_TOP - HITCH_Z, HITCH_X[0] - BRIDGE_X))
    print(f"all {total:.0f} N; down {down:.1f} degrees past the bridge, pressing it on the board with about {total * sin(radians(down)):.0f} N")
    # The string from the post to the nut rod has to clear the nut block's chamfered outer edge.
    (x0, _, z0), (x1, _, z1) = string_path(0)[:2]
    z_at = z0 + (z1 - z0) * (NUT_CHAMFER - x0) / (x1 - x0)
    print(f"string clears the nut block's edge by {z_at - (BOARD_TOP + STRIP_T):.1f} mm")
    # The felt arm runs from the shaft to the felt's top; the lift turns it about X.
    y, z = -SERVO_DY, string_top(0) + FELT[2] - SHAFT_Z
    t = radians(LIFT)
    lifted = y * sin(t) + z * cos(t)
    print(f"felt arm {hypot(y, z):.1f} mm from the shaft; a {-LIFT:.0f} degree lift raises the felt {lifted - z:.1f} mm")
    print(f"damper bar clears the strings by {BAR_Z[0] - string_top(0):.1f} mm")


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

    assembly = []
    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}-frame")
    export_step(model, OUT / f"{NAME}-frame.step")
    shutil.copyfile(__file__, OUT / f"{NAME}-frame-cad.py")
    render(DRAWINGS / f"{NAME}-frame-render.svg", model)
    print("written to", OUT)


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


def damper_closeup():
    """Three dampers on a short length of rail, over their strings, for the close-up drawing."""
    shapes = []
    for i in (2, 3, 4):
        case, arm, spring, anchor = damper(i)
        shapes += [case, arm, spring, anchor]
        y = STRING_Y[i]
        z = ROD_TOP + radius(STRINGS[i][2])
        shapes.append(cyl_x(y, z, radius(STRINGS[i][2]) + 0.3, TOUCH - 40, TOUCH + 60))
    y0, y1 = STRING_Y[2] - 15, STRING_Y[4] + 28
    shapes.append(box(*BAR_X, y0, y1, *BAR_Z))
    shapes.append(vslot(*RAIL_X, y0, y1, *RAIL_Z))
    shapes.append(box(TOUCH - 40, TOUCH + 60, y0, y1, H, BOARD_TOP))
    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-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, model):
    sheet(
        path,
        "Sympathetic Resonance Field: the eight-string frame",
        "Drawn from the CAD model.",
        [
            (model, (-420, -620, 520), (300, 130, 40), "EIGHT STRINGS OVER A SOUNDBOARD, WITH A FELT DAMPER ON EACH"),
            (damper_closeup(), (TOUCH - 150, STRING_Y[3] - 120, 160), (TOUCH + 10, STRING_Y[3] + 8, 82), "A SPRING HOLDS EACH FELT ON ITS STRING; THE SERVO LIFTS IT"),
        ],
    )


if __name__ == "__main__":
    main()
