#!/usr/bin/env python3
"""
bodyboard.py - the body board for the modular legged robot, written as a program.

Single-sided, milled on FR1 with a 1/64" end mill. Eight leg ports, one
half-duplex servo bus, eight port-ID sense channels through an analog mux,
an IMU header, a XIAO RP2040.

The layout idea worth stating: every port connector's four pins are ordered
12V, GND, BUS, ID from the board edge inward, and all eight ports sit on one
edge. The three shared rails then run as nested open rings that never cross
each other or anything else, and all eight ID pins face the empty half of the
board where the mux lives. Pin order is the routing strategy.

Run:  python3 bodyboard.py     -> PNGs for mods, netlist, BOM, DRC report
"""
import json
from fab import Board, Rules, trace

# ------------------------------------------------------------- footprints
def pin(name, x, y, w=1.5, h=1.8, drill=None):
    return dict(name=name, x=x, y=y, w=w, h=h, drill=drill)

def header(names, pitch=2.54, pad=2.0, drill=1.0, vertical=True):
    span = (len(names) - 1) * pitch
    pins = [pin(nm, 0 if vertical else i * pitch - span / 2,
                span / 2 - i * pitch if vertical else 0, pad, pad, drill)
            for i, nm in enumerate(names)]
    return dict(name=f"header1x{len(names)}", pins=pins, label_dy=span / 2 + 2.8)

R1206 = dict(name="R1206", pins=[pin("1", -1.6, 0), pin("2", 1.6, 0)], label_dy=2.0)
R1206V = dict(name="R1206V", pins=[pin("1", 0, 1.6, 1.8, 1.5), pin("2", 0, -1.6, 1.8, 1.5)],
              label_dy=3.2)
# a wire link: two pads far enough apart that several traces pass between them
LINK5 = dict(name="WIRE-LINK-5", pins=[pin("1", 0, 2.5, 1.8, 1.8, 1.0),
                                       pin("2", 0, -2.5, 1.8, 1.8, 1.0)], label_dy=4.0)
LINK = dict(name="WIRE-LINK-12", pins=[pin("1", 0, 6.0, 1.8, 1.8, 1.0),
                                       pin("2", 0, -6.0, 1.8, 1.8, 1.0)], label_dy=7.6)
LINKH = dict(name="WIRE-LINK-12H", pins=[pin("1", -6.0, 0, 1.8, 1.8, 1.0),
                                         pin("2", 6.0, 0, 1.8, 1.8, 1.0)], label_dy=2.4)

def soic_v(n, names, pitch=1.27, row=5.6, pw=1.9, ph=0.62):
    """Long axis vertical: pins 1..n/2 down the LEFT side, n/2+1..n up the RIGHT."""
    half, pins = n // 2, []
    span = (half - 1) * pitch
    for i in range(half):
        pins.append(pin(names[i], -row / 2, span / 2 - i * pitch, pw, ph))
    for i in range(half):
        pins.append(pin(names[half + i], row / 2, -span / 2 + i * pitch, pw, ph))
    return dict(name=f"SOIC-{n}", pins=pins, label_dy=span / 2 + 2.6)

def soic_h(n, names, pitch=1.27, row=5.6, pw=1.9, ph=0.62):
    """Long axis horizontal: pins 1..n/2 along the TOP left-to-right."""
    half, pins = n // 2, []
    span = (half - 1) * pitch
    for i in range(half):
        pins.append(pin(names[i], -span / 2 + i * pitch, row / 2, ph, pw))
    for i in range(half):
        pins.append(pin(names[half + i], span / 2 - i * pitch, -row / 2, ph, pw))
    return dict(name=f"SOIC-{n}", pins=pins, label_dy=row / 2 + 2.6)

MUX = soic_v(16, ["Y4", "Y6", "Z", "Y7", "Y5", "E", "VEE", "GND",
                  "S2", "S1", "S0", "Y3", "Y0", "Y1", "Y2", "VDD"])
BUF = soic_h(14, ["1OE", "1A", "1Y", "2OE", "2A", "2Y", "GND",
                  "3Y", "3A", "3OE", "4Y", "4A", "4OE", "VCC"])
XIAO = dict(name="XIAO-RP2040", label_dy=11.2, pins=[
    *[pin(nm, -8.3, 7.62 - i * 2.54, 2.8, 1.5) for i, nm in
      enumerate(["D0", "D1", "D2", "D3", "D4", "D5", "D6"])],
    *[pin(nm, 8.3, 7.62 - i * 2.54, 2.8, 1.5) for i, nm in
      enumerate(["5V", "GND", "3V3", "D10", "D9", "D8", "D7"])]])
TERM2 = dict(name="TERM-5.08", label_dy=5.4, pins=[
    pin("1", 0, 2.54, 3.6, 3.6, 1.3), pin("2", 0, -2.54, 3.6, 3.6, 1.3)])
ECAP = dict(name="CAP-RADIAL-5", label_dy=4.2, pins=[
    pin("+", -2.5, 0, 2.8, 2.8, 1.0), pin("-", 2.5, 0, 2.8, 2.8, 1.0)])
SMB = dict(name="DO-214AA", label_dy=2.8, pins=[
    pin("A", -2.3, 0, 2.0, 3.2), pin("K", 2.3, 0, 2.0, 3.2)])
TACT = dict(name="SW-6mm", label_dy=4.8, pins=[
    pin("1", -3.25, 2.25, 2.0, 2.0, 1.0), pin("2", 3.25, 2.25, 2.0, 2.0, 1.0),
    pin("3", -3.25, -2.25, 2.0, 2.0, 1.0), pin("4", 3.25, -2.25, 2.0, 2.0, 1.0)])

# ------------------------------------------------------------- geometry
W, H = 110.0, 86.0
PORT_X = [16.0, 27.0, 38.0, 49.0, 60.0, 71.0, 82.0, 93.0]
Y12, YGND, YBUS, YID = 80.5, 77.96, 75.42, 72.88
RAILW, BUSW, SIG = 2.2, 0.8, 0.5

b = Board(W, H, Rules(0.4, 0.4, 0.4, 0.8, dpi=1000), name="bodyboard v0.1")
b.set_pour("GND")          # ground is a pour, so it is never a routing problem
P = {}
def place(ref, val, fp, x, y, nets): P[ref] = b.place(ref, val, fp, x, y, nets=nets); return P[ref]
def T(net, pts, w=SIG): b.add(trace(net, pts, w))
def at(ref, nm): return P[ref][nm][0], P[ref][nm][1]

# ---- eight leg ports along the top edge -----------------------------------
HDR4 = header(["P1", "P2", "P3", "P4"])
for i, x in enumerate(PORT_X):
    place(f"J{i+1}", "leg port", HDR4, x, (Y12 + YID) / 2,
          dict(P1="+12V", P2="GND", P3="BUS", P4=f"ID{i}"))

# ---- the two rails that have to be drawn by hand --------------------------
# 12V is the outermost ring and carries the servo current; BUS sits inside it.
T("+12V", [(6.0, Y12), (103.0, Y12)], RAILW)
T("+12V", [(6.0, Y12), (6.0, 14.0)], RAILW)
T("BUS", [(12.0, YBUS), (106.0, YBUS)], BUSW)
T("BUS", [(106.0, YBUS), (106.0, 10.0)], BUSW)

# ---- parts ----------------------------------------------------------------
# power comes in bottom left, the servo bus buffer sits bottom right next to
# the XIAO's UART pins, and the middle is left clear for the ID fan-in.
place("J9", "12V 20A in", TERM2, 6.0, 8.0, {"1": "+12V", "2": "GND"})
place("D1", "SS34", SMB, 17.0, 13.0, {"A": "+12V", "K": "V12F"})
place("C1", "470u 25V", ECAP, 17.0, 6.0, {"+": "V12F", "-": "GND"})
place("C2", "100n", R1206, 27.0, 6.0, {"1": "V12F", "2": "GND"})
place("J10", "buck 12->5V", header(["IN+", "IN-", "OUT+", "OUT-"], vertical=False),
      38.0, 6.0, {"IN+": "V12F", "IN-": "GND", "OUT+": "+5V", "OUT-": "GND"})
place("C3", "100n", R1206, 52.0, 6.0, {"1": "+5V", "2": "GND"})

MX, MY = 54.0, 46.0
place("U2", "CD74HC4051", MUX, MX, MY, {
    "Z": "IDSENSE", "E": "GND", "VEE": "GND", "GND": "GND", "VDD": "+3V3M",
    "S0": "S0", "S1": "S1", "S2": "S2",
    "Y5": "ID0", "Y7": "ID1", "Y6": "ID2", "Y4": "ID3",
    "Y2": "ID4", "Y1": "ID5", "Y0": "ID6", "Y3": "ID7"})

# The mux's supply pin is the one net that cannot be routed on a single layer:
# it sits behind seven traces leaving the same side of the chip. One wire link
# hops all seven at once. It is the only jumper on the board.
place("JP1", "wire link", LINK, 58.5, 45.5, {"1": "+3V3M", "2": "+3V3"})

place("U1", "XIAO RP2040", XIAO, 80.0, 32.0, {
    "D0": "S0", "D1": "S1", "D2": "S2", "D3": "IDSENSEX", "D4": "SDA", "D5": "SCL",
    "D6": "TX", "5V": "+5V", "GND": "GND", "3V3": "+3V3", "D10": "BTN",
    "D9": "LEDA", "D8": "TXEN_N", "D7": "RXD"})
place("R1", "10k", R1206V, 66.0, 30.0, {"1": "+3V3", "2": "IDSENSEX"})

place("U3", "74HCT125", BUF, 90.0, 14.0, {
    "1OE": "TXEN_N", "1A": "TX", "1Y": "BUS", "VCC": "+5V", "GND": "GND",
    # the three unused buffers get both input and enable tied to ground, so
    # their pads join the pour and cost no routing at all
    "2OE": "GND", "3OE": "GND", "4OE": "GND", "2A": "GND", "3A": "GND", "4A": "GND"})
place("C5", "100n", R1206, 78.0, 6.0, {"1": "+5V", "2": "GND"})
place("R2", "2.2k", R1206, 102.0, 6.0, {"1": "+5V", "2": "BUS"})
place("R3", "3.3k", R1206V, 98.0, 20.0, {"1": "BUS", "2": "RXD"})
place("R4", "6.8k", R1206V, 104.0, 26.0, {"1": "RXD", "2": "GND"})

place("J11", "IMU", header(["3V3", "GND", "SDA", "SCL"], vertical=False), 44.0, 16.0,
      {"3V3": "+3V3", "GND": "GND", "SDA": "SDA", "SCL": "SCL"})
place("R5", "4.7k", R1206V, 60.0, 20.0, {"1": "+3V3", "2": "SDA"})
place("R6", "4.7k", R1206V, 64.0, 20.0, {"1": "+3V3", "2": "SCL"})
# status LED and mode button live on a 3-pin header so they can sit on the
# outside of the robot rather than under the body plate
place("J12", "user I/O", header(["BTN", "GND", "LEDA"]), 101.0, 40.0,
      {"BTN": "BTN", "GND": "GND", "LEDA": "LEDA"})

# ---- the ID fan-in, drawn by hand because the order matters ---------------
# Ports 1-4 enter the mux's left column, 5-8 the right. Within each group the
# port nearest the chip takes the highest channel: that single rule is what
# makes eight parallel runs cross neither each other nor the descents.
LEFTMAP = [(0, "Y5"), (1, "Y7"), (2, "Y6"), (3, "Y4")]
RIGHTMAP = [(7, "Y3"), (6, "Y0"), (5, "Y1"), (4, "Y2")]
for grp, side in ((LEFTMAP, -1), (RIGHTMAP, +1)):
    for p, ch in grp:
        cx, cy, net = P["U2"][ch]
        T(net, [(PORT_X[p], YID), (PORT_X[p], cy), (cx + side * 1.4, cy)])

# pin escapes: every pin gets a short stub pointing away from its package
# before the router starts, so a trace leaving one pin can never run alongside
# its neighbour. This is what stops fine-pitch parts shorting.
def escape(ref, names=None, vec=None, length=1.8):
    for nm, (x0, y0, net) in P[ref].items():
        if names and nm not in names: continue
        if vec is None:
            cx = sum(v[0] for v in P[ref].values()) / len(P[ref])
            cy = sum(v[1] for v in P[ref].values()) / len(P[ref])
            dx, dy = x0 - cx, y0 - cy
            n = (dx * dx + dy * dy) ** .5 or 1.0
            dx, dy = dx / n, dy / n
        else:
            dx, dy = vec
        T(net, [(x0, y0), (x0 + dx * length, y0 + dy * length)])

escape("U1", ["D0", "D1", "D2", "D3", "D4", "D5", "D6"], (-1, 0), 2.4)
escape("U1", ["5V", "3V3", "D10", "D9", "D8", "D7"], (1, 0), 2.4)
for nm, side in (("S0", +1), ("S1", +1), ("S2", +1), ("Z", -1)):
    x0, y0 = at("U2", nm)
    T(P["U2"][nm][2], [(x0, y0), (x0 + side * 1.6, y0)])
escape("U3", ["1OE", "1A", "1Y"], (0, 1), 1.8)
escape("U3", ["VCC"], (0, -1), 1.8)
for ref in ("R1", "R3", "R4", "R5", "R6"): escape(ref, length=1.5)
for ref in ("C2", "C3", "C5", "R2"): escape(ref, length=1.5)
escape("J10", vec=(0, -1), length=2.0)
escape("J11", vec=(0, -1), length=2.0)
escape("J12", vec=(-1, 0), length=2.0)
escape("J9", vec=(1, 0), length=3.0)
escape("D1", length=1.6)

# The common pin of the mux sits between two channel pins and is fenced in by
# the two lowest fan-in runs. One wire link hops both; it is the only jumper
# the layer forces on us.
place("JP2", "wire link", LINK5, 34.0, 46.0, {"1": "IDSENSE", "2": "IDSENSEX"})
T("IDSENSE", [(at("U2", "Z")[0] - 1.6, at("U2", "Z")[1]), (34.0, 47.905), (34.0, 48.5)])


# select lines: three parallel runs. The highest destination turns down
# furthest right, which is the order that keeps them from crossing.
for nm, xturn in (("S0", 66.0), ("S1", 64.0), ("S2", 62.0)):
    sx, sy, net = P["U2"][nm]
    dx, dy = at("U1", {"S0": "D0", "S1": "D1", "S2": "D2"}[nm])
    T(net, [(sx + 1.6, sy), (xturn, sy), (xturn, dy), (dx - 2.4, dy)])

b.text(W / 2, H - 3.2, "MODBOT BODY BOARD v0.1   A.JEGER   MAS.863 2026")

# ------------------------------------------------------------------ route
# longest and most constrained first; the easy top-edge ID runs go last
ORDER = (["IDSENSEX", "+3V3M", "+5V", "+3V3", "V12F", "BUS",
          "TX", "TXEN_N", "RXD", "SDA", "SCL", "LEDA", "BTN"])
WIDTH = {"+12V": 2.2, "V12F": 1.2, "+5V": 1.0, "BUS": 0.6}

if __name__ == "__main__":
    from fab import Router, circle
    r = Router(b, grid=0.25)
    failed = [n for n in ORDER if not r.route_net(n, WIDTH.get(n, SIG))]
    print(f"routed {len(ORDER) - len(failed)}/{len(ORDER)} nets"
          + (f", failed: {failed}" if failed else ""))
    rep = b.drc()
    rep["unrouted"] = failed
    b.export_png("bodyboard")
    nets = b.export_netlist("bodyboard.net")
    b.export_bom("bodyboard_bom.csv")
    json.dump(rep, open("drc_report.json", "w"), indent=1)
    print(f"{len(b.parts)} parts, {len(nets)} nets, {len(b.drills)} drilled holes")
