"""
Bridge box: the printed enclosure for Control's bridge, the one board that
transmits. A tub and a lid. The lid carries the stop button, the activate
button, the status light and the antenna; the board lies on the floor, held
by a zip tie; the USB cable comes in through a slot in one end.

Run from the cad/ folder:

    uv run bridge-box/model.py

It writes, for the site:
    site/public/files/control/stl/*.stl        printed parts, laid flat for printing
    site/public/files/control/bridge-box-stl.zip
    site/public/files/control/bridge-box.step  the whole assembly, for editing
    site/public/files/control/bridge-box.glb   the 3D model shown on the page
    site/public/files/control/bridge-box-motion.json
    site/public/files/control/bridge-box-cad.py this script
    site/src/content/instruments/bridge/drawings/bridge-box-render.svg

Coordinates, in millimeters: X runs along the box from the cable end (0) to
the stop-button end (L). Y runs across it, front (0) to back (W). Z is up;
the floor's underside is at 0 and the tub's rim at H.

The board doesn't need to line up with any hole: the cable plugs in inside the
box, so the ESP32-S3 board, with or without a screw-terminal expansion board,
can sit anywhere on the floor between the two zip-tie slots.

All numbers are a first design, untested in print. Measure your buttons before
printing and change the hole sizes below to match.
"""

from pathlib import Path
import re
import shutil
import sys
import zipfile

from build123d import (
    Axis, Box, Circle, Color, Compound, Cylinder, ExportSVG, Plane, Pos, Rot, Spline, Unit,
    export_gltf, export_step, export_stl, sweep,
)

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

REPO = HERE.parent.parent
OUT = REPO / "site/public/files/control"
DRAWINGS = REPO / "site/src/content/instruments/bridge/drawings"

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

L, W, H = 140.0, 100.0, 72.0  # tub outside, without the lid. H leaves room under the stop button.
WALL = 2.4  # six lines of a 0.4 mm nozzle
FLOOR = 3.0  # thick enough for the zip-tie groove underneath
R = 6.0  # outside corner radius

LID_T = 3.0  # most panel buttons clamp panels up to about 5 mm
RIM_H, RIM_W, FIT = 3.0, 1.6, 0.3  # the lip under the lid that locates it inside the walls

BOSS_R = 4.5  # corner posts for the lid screws
BOSS_C = WALL + BOSS_R - 0.5  # post center, in from each outside edge
INSERT_R, INSERT_D = 2.1, 6.0  # M3 heat-set insert hole
SCREW_R = 1.7  # M3 clearance in the lid

CY = W / 2  # everything on the lid sits on the center line
ANT_X, LED_X, REL_X, GO_X, STOP_X = 14.0, 28.0, 50.0, 72.0, 108.0
STOP_D = 22.4  # standard 22 mm panel button
RELEASE_D = 16.2  # a 16 mm momentary button; change for 12 or 19 mm
GO_BUTTON = False  # True adds a third hole, the same size, for a Go button
LED_D = 8.0  # a 5 mm LED in a panel holder
SMA_D = 6.5  # SMA bulkhead thread is 1/4 inch

USB_SLOT = (16.0, 9.0, 20.0)  # width, height and center height of the cable slot; a USB-C plug passes through
TIE_X, TIE_SPAN, TIE_SLOT = 55.0, 76.0, (5.0, 2.6)  # zip-tie slots: across the floor, this far apart
MOUNT_DX, MOUNT_R = 60.0, 2.7  # two M5 holes in the floor, for T-nuts in a 20 mm extrusion slot

# ---------------------------------------------------------------------------
# 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_z(x, y, r, z0, z1):
    return Pos(x, y, (z0 + z1) / 2) * Cylinder(r, z1 - z0)


def rounded(x0, x1, y0, y1, z0, z1, r):
    """A box with its four vertical edges rounded."""
    b = box(x0, x1, y0, y1, z0, z1)
    return b.fillet(r, b.edges().filter_by(Axis.Z))


def usb_cable(route=None, end_dir=None):
    """The USB cable: its USB-C plug through the slot in the end, a round cable along `route`, and a USB-A plug at
    the far end, for the computer, pointing along `end_dir` (by default, the way the route's last stretch runs).
    The default route drops to the desk and curves away to the front."""
    z = USB_SLOT[2]
    if route is None:
        route = [(-26, CY, z), (-58, CY, 9), (-80, CY - 18, 2.2), (-96, CY - 75, 2.2), (-120, CY - 135, 2.2)]
    plug_c = box(-20, 4, CY - 6, CY + 6, z - 3.25, z + 3.25) + Pos(-23, CY, z) * Rot(0, 90, 0) * Cylinder(3, 6)
    end_dir = end_dir or tuple(b - a for a, b in zip(route[-2], route[-1]))
    path = Spline(*route, tangents=[(-1, 0, 0), end_dir])
    cable = sweep(Plane(origin=path @ 0, z_dir=path % 0) * Circle(2.2), path=path)
    end = Plane(origin=path @ 1, z_dir=path % 1)
    plug_a = end * (Pos(0, 0, 3) * Cylinder(3, 6) + Pos(0, 0, 15) * Box(16, 8.5, 18) + Pos(0, 0, 30) * Box(12, 4.5, 12))
    return plug_c + cable + plug_a


def corners(inset):
    return [(x, y) for x in (inset, L - inset) for y in (inset, W - inset)]


# ---------------------------------------------------------------------------
# Printed parts, in assembly position
# ---------------------------------------------------------------------------


def tub():
    """The box itself, open at the top. Prints upright with no supports."""
    p = rounded(0, L, 0, W, 0, H, R)
    p -= rounded(WALL, L - WALL, WALL, W - WALL, FLOOR, H + 1, R - WALL)
    for x, y in corners(BOSS_C):
        p += cyl_z(x, y, BOSS_R, FLOOR, H)
        p -= cyl_z(x, y, INSERT_R, H - INSERT_D, H + 1)

    w, h, zc = USB_SLOT
    p -= box(-1, WALL + 1, CY - w / 2, CY + w / 2, zc - h / 2, zc + h / 2)

    sx, sy = TIE_SLOT
    for y in (CY - TIE_SPAN / 2, CY + TIE_SPAN / 2):
        p -= box(TIE_X - sx / 2, TIE_X + sx / 2, y - sy / 2, y + sy / 2, -1, FLOOR + 1)
    p -= box(TIE_X - sx / 2, TIE_X + sx / 2, CY - TIE_SPAN / 2 - 2, CY + TIE_SPAN / 2 + 2, -1, 1.4)  # tie lies flush underneath

    for dx in (-MOUNT_DX / 2, MOUNT_DX / 2):
        p -= cyl_z(L / 2 + dx, CY, MOUNT_R, -1, FLOOR + 1)
    return p


def lid_holes():
    """The bridge's own lid: everything on the center line. (x, y, diameter)"""
    holes = [(STOP_X, CY, STOP_D), (REL_X, CY, RELEASE_D), (LED_X, CY, LED_D), (ANT_X, CY, SMA_D)]
    if GO_BUTTON:
        holes.append((GO_X, CY, RELEASE_D))
    return holes


def lid(holes=None, windows=()):
    """Prints top face down, so the lip is on top and nothing needs support.
    holes: (x, y, diameter), the bridge's own by default. windows: (x, y, width, height) cutouts."""
    p = rounded(0, L, 0, W, H, H + LID_T, R)
    rim = rounded(WALL + FIT, L - WALL - FIT, WALL + FIT, W - WALL - FIT, H - RIM_H, H, R - WALL - FIT)
    rim -= rounded(WALL + FIT + RIM_W, L - WALL - FIT - RIM_W, WALL + FIT + RIM_W, W - WALL - FIT - RIM_W, H - RIM_H - 1, H + 1, 1)
    for x, y in corners(BOSS_C):
        rim -= cyl_z(x, y, BOSS_R + 0.6, H - RIM_H - 1, H + 1)
    p += rim
    for x, y in corners(BOSS_C):
        p -= cyl_z(x, y, SCREW_R, H - 1, H + LID_T + 1)

    for x, y, d in lid_holes() if holes is None else holes:
        p -= cyl_z(x, y, d / 2, H - RIM_H - 1, H + LID_T + 1)
    for x, y, w, h in windows:
        p -= box(x - w / 2, x + w / 2, y - h / 2, y + h / 2, H - RIM_H - 1, H + LID_T + 1)
    return p


# (name, builder, rotation that lays it flat for printing)
PRINTED = [
    ("tub", tub, Rot(0, 0, 0)),
    ("lid", lid, Rot(180, 0, 0)),
]

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

TOP = H + LID_T  # the lid's top face
ON_LID = ["printed:lid", "bought:stop-button", "bought:release-button", "bought:status-light", "bought:antenna"]


def bought_parts():
    z0 = FLOOR + 2  # on a strip of foam tape
    parts = [
        (
            "stop-button",
            cyl_z(STOP_X, CY, 20, TOP + 8, TOP + 22)
            + cyl_z(STOP_X, CY, 15, TOP, TOP + 8)
            + box(STOP_X - 15, STOP_X + 15, CY - 20, CY + 20, H - 45, H - RIM_H),
            (0.8, 0.15, 0.12),
        ),
        ("release-button", cyl_z(REL_X, CY, 9, TOP, TOP + 7) + cyl_z(REL_X, CY, 8, H - 28, H - RIM_H), (0.15, 0.15, 0.15)),
        ("status-light", cyl_z(LED_X, CY, 4.5, TOP, TOP + 3) + cyl_z(LED_X, CY, 2.5, TOP + 3, TOP + 7), (0.92, 0.92, 0.9)),
        ("antenna", cyl_z(ANT_X, CY, 5, TOP, TOP + 12) + cyl_z(ANT_X, CY, 4.5, TOP + 12, TOP + 190), (0.12, 0.12, 0.12)),
        ("expansion-board", box(10, 82, CY - 30, CY + 30, z0, z0 + 12), (0.1, 0.25, 0.15)),
        ("esp32-s3-board", box(14, 83, CY - 14, CY + 14, z0 + 12, z0 + 17), (0.12, 0.12, 0.14)),
        ("zip-tie", box(TIE_X - 2, TIE_X + 2, CY - TIE_SPAN / 2, CY + TIE_SPAN / 2, z0 + 17, z0 + 18.2), (0.9, 0.9, 0.85)),
        ("usb-cable", usb_cable(), (0.2, 0.2, 0.22)),
    ]
    return parts


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


def lay_flat(shape, rotation):
    s = rotation * shape
    bb = s.bounding_box()
    return Pos(-(bb.min.X + bb.max.X) / 2, -(bb.min.Y + bb.max.Y) / 2, -bb.min.Z) * s


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

    assembly = []
    print(f"{'part':8} {'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:8} {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"bridge-box-{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="bridge-box")
    export_step(model, OUT / "bridge-box.step")
    export_gltf(model, OUT / "bridge-box.glb", unit=Unit.MM, binary=True, linear_deflection=0.08, angular_deflection=0.3)
    compact_glb(OUT / "bridge-box.glb")

    write_motion(
        OUT / "bridge-box-motion.json",
        duration=7,
        tracks=[slide(ON_LID, (0, 0, 1), [(0, 0), (0.8, 0), (2.2, 70), (4.8, 70), (6.2, 0), (7, 0)])],
        captions=[
            (0, "The lid carries the stop button, the activate button, the light and the antenna."),
            (2.2, "Underneath, the board lies on the floor, held by one zip tie. The cable comes in at the end."),
            (4.8, "Four screws hold the lid down."),
        ],
    )

    with zipfile.ZipFile(OUT / "bridge-box-stl.zip", "w", zipfile.ZIP_DEFLATED) as z:
        for stl in sorted((OUT / "stl").glob("bridge-box-*.stl")):
            z.write(stl, f"bridge-box/{stl.name}")
    shutil.copyfile(__file__, OUT / "bridge-box-cad.py")
    DRAWINGS.mkdir(parents=True, exist_ok=True)
    render(model, DRAWINGS / "bridge-box-render.svg")
    print("written to", OUT)


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(model, path):
    """The box closed, from the front corner, with its antenna up and its cable running off to the computer."""
    sheet(
        path,
        "Bridge: the box",
        "Drawn from the CAD model.",
        [(model, (L + 160, -170, H + 180), (L / 2 - 40, W / 2 - 40, H / 2 + 30), "THE BOX: STOP, ACTIVATE AND LIGHT ON THE LID, THE ANTENNA, AND THE CABLE TO THE COMPUTER")],
    )


if __name__ == "__main__":
    main()
