"""
Sympathetic Resonance Field: an autoharp lies on its back in a V-slot cradle,
and nobody plays it. Its chord bars stay up, so all its strings are free. A
surface transducer on its back shakes the box with breaths of noise the ESP32
makes, each narrowed to a note, and the strings that have that note as their
own pitch or as an overtone pick it out and ring on after the breath.

Run from the cad/ folder:

    uv run sympathetic-resonance-field/model.py

It writes, for the site:
    site/public/files/sympathetic-resonance-field/sympathetic-resonance-field.step     the whole assembly, for editing
    site/public/files/sympathetic-resonance-field/sympathetic-resonance-field.glb      the 3D model shown on the page
    site/public/files/sympathetic-resonance-field/sympathetic-resonance-field-cad.py   this script
    site/src/content/instruments/sympathetic-resonance-field/drawings/sympathetic-resonance-field-render.svg

Two other versions have their own scripts: servos.py presses the chord bars
with servos, and frame.py builds eight strings on a V-slot frame instead.

The autoharp is bought and drawn as a simple shape: a typical 15-bar, 36-string
model. Nothing is printed and nothing moves, so there is no motion file.
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 log2
from pathlib import Path
import re
import shutil
import sys

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

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

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)


# The chord bars stay up and touch nothing. They're drawn as sold, each felt notched under its chord's strings.
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))]
BAR_W, BAR_Y = 7.0, (14.0, 296.0)
FELT_Z = STRING_Z + drawn(0) + 3.0  # the felt's underside, 3 mm clear of the strings with the bar up
FELT_T, BAR_T = 3.0, 12.0
BUTTON_R, BUTTON_H = 5.0, 7.0
BUTTON_ROWS = (95.0, 150.0, 205.0)
BUTTON_TOP = FELT_Z + FELT_T + BAR_T + BUTTON_H
HOLDER_X = (BAR_X[0] - 10, BAR_X[-1] + 10)

# The cradle: two 2040 rails lying flat across the autoharp, rubber on top. The second
# rail runs on past the autoharp to carry the battery, the electronics and the stop button.
RAILS = ((130.0, (-40.0, 350.0)), (470.0, (-100.0, 450.0)))  # (middle, from, to)
TRANSDUCER = (300.0, 140.0)  # on the autoharp's back, a little off its middle


# ---------------------------------------------------------------------------
# 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 tube_z(x, y, r_in, r_out, z0, z1):
    return cyl_z(x, y, r_out, z0, z1) - cyl_z(x, y, r_in, z0 - 1, z1 + 1)


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 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 frequency(note):
    names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
    octave = int(note[len(pitch_class(note)) :])
    return 440 * 2 ** ((names.index(pitch_class(note)) + 12 * (octave + 1) - 69) / 12)


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)


def answering(note, partials=8, cents=15):
    """The strings a breath at this note wakes: each whose note, or one of its overtones, lands within a few cents."""
    f = frequency(note)
    found = []
    for s in NOTES:
        for n in range(1, partials + 1):
            if abs(1200 * log2(n * frequency(s) / f)) < cents:
                found.append((s, n))
    return found


# ---------------------------------------------------------------------------
# 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
    h = STRING_Z - 0.5 - TOP
    head_bridge = Plane(origin=p0, x_dir=(d[1] / length, -d[0] / length, 0), z_dir=d).location * Pos(0, h / 2, length / 2) * Box(6, h, 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:])]))

    bars = []
    for k, x in enumerate(BAR_X):
        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)
        bar += cyl_z(x, BUTTON_ROWS[k % 3], BUTTON_R, FELT_Z + FELT_T + BAR_T, BUTTON_TOP)
        bars.append(bar + felt)
    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)),
        ("chord-bars", Compound(children=bars) + holder, (0.25, 0.2, 0.15)),
    ]


# ---------------------------------------------------------------------------
# The transducer
# ---------------------------------------------------------------------------


def transducer_parts(x=0.0, y=0.0, z=0.0, flip=False):
    """A 32 mm surface transducer, standing on its foot at (x, y, z), or hanging from it with flip.
    Inside: a coil on a short tube, fixed to the foot, sits in the gap of a heavy magnet that hangs on a
    soft ring. The audio current in the coil pushes the foot one way and the magnet the other."""
    parts = {
        "foot": cyl_z(0, 0, 16, 0, 2.5),
        "coil": tube_z(0, 0, 9.6, 10.0, 2.5, 12) + tube_z(0, 0, 10.0, 10.8, 7.0, 12),
        "magnet": (tube_z(0, 0, 11.6, 15, 6, 22) + cyl_z(0, 0, 15, 19, 22)) + cyl_z(0, 0, 8.8, 12.5, 19) + cyl_z(0, 0, 9.3, 8.5, 12.5),
        "housing": tube_z(0, 0, 15.6, 16.2, 2.5, 11) + tube_z(0, 0, 11.8, 16.2, 6.0, 6.6),
        "ears": box(-23, -16, -5, 5, 0, 2.5) + box(16, 23, -5, 5, 0, 2.5),
    }
    place = Pos(x, y, z) * (Rot(180, 0, 0) if flip else Rot(0, 0, 0))
    return {k: place * v for k, v in parts.items()}


# ---------------------------------------------------------------------------
# The cradle and the electronics
# ---------------------------------------------------------------------------


def cradle():
    rails = Compound(children=[vslot(x - 20, x + 20, y0, y1, -20, 0) for x, (y0, y1) in RAILS])
    pads = Compound(children=[box(x - 20, x + 20, 0, outline_y_max(x + 20), 0, PAD) for x, _ in RAILS])
    stops, stop_pads = [], []
    for x, y_edge, dy in ((RAILS[0][0], 0, -1), (RAILS[0][0], 310, 1), (RAILS[1][0], 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, (y0, y1) in RAILS for y in (y0 + 15, y1 - 15)])
    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 electronics():
    """The transducer under the autoharp's back; on the second rail beyond the autoharp, the battery at the
    front, and the box and the stop button at the back."""
    x = RAILS[1][0]
    t = transducer_parts(*TRANSDUCER, PAD, flip=True)
    return [
        ("transducer", Compound(children=list(t.values())), (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)),
    ]


# ---------------------------------------------------------------------------
# Build and export
# ---------------------------------------------------------------------------


def report():
    for note in ("G4", "C5", "E5"):
        found = answering(note)
        print(f"a breath at {note} wakes {len(found)} strings: " + ", ".join(f"{s}" + (f" (overtone {n})" if n > 1 else "") for s, n in found))
    print(f"the bars' felts sit {FELT_Z - STRING_Z - drawn(0):.1f} mm clear of the strings, so they touch nothing")


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

    assembly = []
    for name, shape, rgb in autoharp() + cradle() + electronics():
        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.1, angular_deflection=0.3)
    compact_glb(OUT / f"{NAME}.glb")
    shutil.copyfile(__file__, OUT / f"{NAME}-cad.py")
    render(DRAWINGS / f"{NAME}-render.svg", model)
    print("written to", OUT)


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


def transducer_cutaway():
    """The transducer standing on a piece of the box, cut in half, with its magnet lifted off the coil."""
    keep = box(-40, 40, 0, 40, -10, 45)
    shapes = [(Pos(0, 0, 16) * v if k == "magnet" else v) & keep for k, v in transducer_parts().items()]
    shapes.append(box(-40, 40, 0, 30, -5, 0))  # the piece of the box it's stuck to, cut the same way
    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):
    sheet(
        path,
        "Sympathetic Resonance Field",
        "Drawn from the CAD model.",
        [
            (model, (-380, -620, 560), (300, 160, 40), "AN AUTOHARP ON ITS BACK IN A V-SLOT CRADLE, ITS STRINGS ALL FREE"),
            (model, (-150, -520, -420), (300, 160, 0), "FROM BELOW: THE TRANSDUCER ON THE AUTOHARP'S BACK, BETWEEN THE RAILS"),
            (transducer_cutaway(), (-70, -110, 60), (0, 0, 16), "INSIDE THE TRANSDUCER, ITS MAGNET LIFTED: THE COIL ON THE FOOT FITS INTO ITS GAP"),
        ],
    )


if __name__ == "__main__":
    main()
