main
Rithesh03 Update 3: PCB design completed - routed board, manufacturing package (prototype, do not order yet), WLED guide, final checklist, Hydrogen feedback 1ed072d 13d ago
"""Check the placement step: LED count/letters/order/orientation, keep-outs, USB-C, markers.

Usage: python3 check_placement.py
"""
import math
import os
import sys

import pcbnew

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import make_board as mb  # noqa: E402

TO = pcbnew.ToMM
errors, lines = [], []


def check(cond, msg):
    (lines.append("OK   " + msg) if cond else errors.append(msg))


def xy(v):
    return TO(v.x) - mb.X0, TO(v.y) - mb.Y0


def bbox(item):
    b = item.GetBoundingBox()
    return (TO(b.GetLeft()) - mb.X0, TO(b.GetTop()) - mb.Y0, TO(b.GetRight()) - mb.X0, TO(b.GetBottom()) - mb.Y0)


def overlap(a, b, margin=0.0):
    return not (a[2] + margin <= b[0] or b[2] + margin <= a[0] or a[3] + margin <= b[1] or b[3] + margin <= a[1])


board = pcbnew.LoadBoard(sys.argv[1] if len(sys.argv) > 1 else mb.PCB)
fps = {f.GetReference(): f for f in board.GetFootprints()}
for f in fps.values():
    f.BuildCourtyardCaches()


def courtyard(f):
    b = f.GetCourtyard(pcbnew.F_CrtYd).BBox()
    return (TO(b.GetLeft()) - mb.X0, TO(b.GetTop()) - mb.Y0, TO(b.GetRight()) - mb.X0, TO(b.GetBottom()) - mb.Y0)


def pad(f, n):
    for p in f.Pads():
        if p.GetNumber() == n:
            return p


# ---- 1. count and letters ------------------------------------------------------------
leds = sorted((r for r in fps if r.startswith("LED")), key=lambda r: int(r[3:]))
check(len(leds) == 107 and leds == [f"LED{n}" for n in range(1, 108)], f"exactly {len(leds)} LEDs, LED1..LED107")
SCHEM = {"R": (1, 18), "I": (19, 29), "T": (30, 40), "H1": (41, 57), "E": (58, 75), "S": (76, 90), "H2": (91, 107)}
names = ["R", "I", "T", "H1", "E", "S", "H2"]
count = {n: 0 for n in names}
for r in leds:
    x, y = xy(fps[r].GetPosition())
    col = round((x - mb.COL0_X) / mb.PITCH)
    row = round((y - mb.ROW0_Y) / mb.PITCH)
    li, c = divmod(col, 6)
    on_grid = abs(x - (mb.COL0_X + col * mb.PITCH)) < 1e-3 and abs(y - (mb.ROW0_Y + row * mb.PITCH)) < 1e-3
    pattern_ok = c < 5 and mb.LETTERS[mb.WORD[li]][row][c] == "1"
    lo, hi = SCHEM[names[li]]
    if not (on_grid and pattern_ok and lo <= int(r[3:]) <= hi):
        errors.append(f"{r} at ({x:.1f}, {y:.1f}) is off-grid, outside its letter pattern, or in the wrong letter")
    count[names[li]] += 1
want = {"R": 18, "I": 11, "T": 11, "H1": 17, "E": 18, "S": 15, "H2": 17}
check(count == want, "per letter: " + ", ".join(f"{k.rstrip('12')}={v}" for k, v in count.items())
      + " (R 18, I 11, T 11, H 17, E 18, S 15, H 17)")
check(True, "every LED sits on the 7 mm grid inside its letter's 5 x 7 pattern, in the schematic's letter range")

# ---- 2. chain order matches the schematic nets ------------------------------------------
first_net = pad(fps["LED1"], "4").GetNetname()
check(first_net.endswith("LED_DIN"), f"LED1 data-in is on '{first_net}' (from the level shifter)")
breaks = [n for n in range(1, 107)
          if pad(fps[f"LED{n}"], "2").GetNetname() != pad(fps[f"LED{n + 1}"], "4").GetNetname()]
check(not breaks, "LED n data-out and LED n+1 data-in share a net for n = 1..106 (chain order = schematic)")
check(pad(fps["LED107"], "2").GetNetname() in ("", None) or pad(fps["LED107"], "2").GetNetname().startswith("unconnected"),
      "LED107 data-out is unconnected (end of chain)")
pw = [r for r in leds if pad(fps[r], "1").GetNetname() != "+5V" or pad(fps[r], "3").GetNetname() != "GND"]
check(not pw, "every LED: pin 1 on +5V, pin 3 on GND")

# ---- 3. orientation ---------------------------------------------------------------------
# DIN (pin 4) and DOUT (pin 2) sit on diagonally opposite corners. For each LED, try all four
# rotations: if any rotation makes DOUT face the next LED AND DIN face the previous one, the
# placed rotation must do so too. If none can (the chain turns back on itself: previous and
# next LED on the same side), it is an unavoidable "turn" LED and must use the best rotation.
def facing(f, c, prev, nxt):
    """How far each data pad sits towards its neighbour (mm, along the unit direction)."""
    dout, din = xy(pad(f, "2").GetPosition()), xy(pad(f, "4").GetPosition())

    def along(p, target):
        dx, dy = target[0] - c[0], target[1] - c[1]
        d = math.hypot(dx, dy)
        return ((p[0] - c[0]) * dx + (p[1] - c[1]) * dy) / d
    so = along(dout, nxt) if nxt else 1.0
    si = along(din, prev)
    return so, si


bad, turns, rots = [], [], {}
for i, r in enumerate(leds):
    f = fps[r]
    c = xy(f.GetPosition())
    nxt = xy(fps[leds[i + 1]].GetPosition()) if i + 1 < len(leds) else None
    prev = xy(fps[leds[i - 1]].GetPosition()) if i else (c[0] - mb.PITCH, c[1])
    placed = round(f.GetOrientationDegrees()) % 360
    got = facing(f, c, prev, nxt)
    options = {}
    for rot in (0, 90, 180, 270):
        f.SetOrientationDegrees(rot)
        options[rot] = facing(f, c, prev, nxt)
    f.SetOrientationDegrees(placed)
    rots[r] = placed
    possible = any(so > 0 and si > 0 for so, si in options.values())
    if possible and not (got[0] > 0 and got[1] > 0):
        bad.append(f"{r}: a better rotation exists")
    elif not possible:
        if min(got) + 1e-6 < max(min(v) for v in options.values()):
            bad.append(f"{r}: turn LED not using its best rotation")
        turns.append(r)
check(not bad, f"every LED uses its best rotation: {107 - len(turns)} LEDs have data-in facing the previous "
      f"LED and data-out facing the next one")
lines.append(f"     {len(turns)} unavoidable turn LEDs (previous and next LED on the same side; one data "
             f"pin must face away): {', '.join(turns)}")
dist = {}
for v in rots.values():
    dist[v] = dist.get(v, 0) + 1
lines.append("     rotations: " + ", ".join(f"{k} deg x {v}" for k, v in sorted(dist.items())))

# ---- 4. overlaps --------------------------------------------------------------------------
cy = {r: courtyard(f) for r, f in fps.items()}
rect_parts = sorted(r for r in cy if not r.startswith("H"))      # mounting holes are circles, checked below
ov = [(a, b) for i, a in enumerate(rect_parts) for b in rect_parts[i + 1:] if overlap(cy[a], cy[b])]
check(not ov, "no two part courtyards overlap (LEDs, ESP32, USB-C)" + (f": {ov[:5]}" if ov else ""))


def rect_circle_gap(r, cx, cy_, rad):
    dx = max(r[0] - cx, 0, cx - r[2])
    dy = max(r[1] - cy_, 0, cy_ - r[3])
    return math.hypot(dx, dy) - rad


g = min((rect_circle_gap(cy[r], hx, hy, mb.HOLE_KEEPOUT_R), r) for r in rect_parts for hx, hy in mb.HOLES)
check(g[0] > 0, f"no part enters a mounting-hole keep-out (closest: {g[1]} courtyard {g[0]:.2f} mm outside the "
      f"3.5 mm keep-out, also clear of the 3.45 mm hole courtyard)")

# ---- 4b. mechanical margins: holes and ESP32 --------------------------------------------
def body(r):
    x, y = xy(fps[r].GetPosition())
    return (x - 2.5, y - 2.5, x + 2.5, y + 2.5)


def pads_box(r):
    bs = [bbox(p) for p in fps[r].Pads()]
    return (min(q[0] for q in bs), min(q[1] for q in bs), max(q[2] for q in bs), max(q[3] for q in bs))


WASHER_R = 3.5                           # M3 washer DIN 125-A, 7.0 mm outer diameter
cg = min((rect_circle_gap(cy[r], hx, hy, mb.HOLE_KEEPOUT_R), r) for r in leds for hx, hy in mb.HOLES)
bg = min((rect_circle_gap(body(r), hx, hy, WASHER_R), r) for r in leds for hx, hy in mb.HOLES)
pg = min((rect_circle_gap(pads_box(r), hx, hy, WASHER_R), r) for r in leds for hx, hy in mb.HOLES)
check(cg[0] >= 0.5, f"hole keep-out to nearest LED courtyard {cg[0]:.2f} mm ({cg[1]}; target >= 0.5 mm)")
check(bg[0] >= 1.0, f"7 mm washer edge to nearest LED body {bg[0]:.2f} mm, to its pads {pg[0]:.2f} mm (target >= 1.0 mm)")
check(abs(mb.HOLE_INSET - 1.6 - 1.6) < 1e-6, f"hole edge to board edge {mb.HOLE_INSET - 1.6:.1f} mm (= 1.6 mm board thickness)")
u4top = xy(fps["U4"].GetPosition())[1] - mb.ESP_H / 2
near_u4 = [r for r in leds if cy[r][2] > cy["U4"][0] and cy[r][0] < cy["U4"][2]]
eb = min((u4top - body(r)[3], r) for r in near_u4)
ep = min((u4top - pads_box(r)[3], r) for r in near_u4)
ec = min((cy["U4"][1] - cy[r][3], r) for r in near_u4)
check(eb[0] >= 1.0 and ec[0] > 0, f"ESP32 to LED above it ({eb[1]}): bodies {eb[0]:.2f} mm, LED pads to module "
      f"{ep[0]:.2f} mm, courtyards {ec[0]:.2f} mm apart (JLCPCB recommends >= 1.0 mm between IC-type parts)")

# ---- 4c. every pad that should have a net has one -----------------------------------------
NC_OK = {("U4", n) for n in ["6", "18", "20", "21", "4", "7", "9", "10", "15", "17", "24", "25", "28", "29",
                             "32", "33", "34", "35"]} | {("J1", "A8"), ("J1", "B8"), ("LED107", "2")}
missing = [f"{r}.{p.GetNumber()}" for r, f in fps.items() if not r.startswith("H") for p in f.Pads()
           if p.GetNumber() != ""           # un-numbered pads are mechanical holes (J1 locating pegs)
           and (p.GetNetname() in ("", None) or p.GetNetname().startswith("unconnected")) and (r, p.GetNumber()) not in NC_OK]
check(not missing, "every pad has its schematic net (only intended no-connect pins, the M3 holes and J1's two "
      "plastic locating-peg holes are without one)"
      + (f": {missing[:8]}" if missing else ""))

# ---- 5. antenna keep-out + 15 mm side clearance ------------------------------------------
zones = []
for z in board.Zones():
    if z.GetIsRuleArea():
        zones.append((z.GetZoneName(), bbox(z)))
for z in fps["U4"].Zones():
    zones.append(("U4 antenna keep-out", bbox(z)))
ant = [z for z in zones if "ntenna" in z[0]]
check(len(ant) == 3, "antenna areas present: " + ", ".join(f"{n} x {b[0]:.1f}-{b[2]:.1f} y {b[1]:.1f}-{b[3]:.1f}" for n, b in ant))
intruders = []
for n, zb in ant:
    zin = (zb[0] + 0.01, zb[1] + 0.01, zb[2] - 0.01, zb[3] - 0.01)
    for r, f in fps.items():
        for p in f.Pads():
            if overlap(bbox(p), zin):
                intruders.append(f"{r} pad {p.GetNumber()} in {n}")
        if r != "U4" and overlap(cy[r], zin):
            intruders.append(f"{r} body in {n}")
    from shapely.geometry import LineString, Point, Polygon, box as sbox
    zpoly = sbox(*zin)
    for t in board.GetTracks():                  # exact copper shape, not the bounding box
        if isinstance(t, pcbnew.PCB_VIA):
            g = Point(*xy(t.GetPosition())).buffer(TO(t.GetWidth()) / 2)
        else:
            g = LineString([xy(t.GetStart()), xy(t.GetEnd())]).buffer(TO(t.GetWidth()) / 2)
        if g.intersects(zpoly):
            intruders.append(f"track/via of {t.GetNetname()} in {n} at {xy(t.GetPosition())}")
    for z in board.Zones():                      # the filled copper, not the zone outline
        if z.GetIsRuleArea():
            continue
        for layer in (pcbnew.F_Cu, pcbnew.B_Cu):
            if not z.IsOnLayer(layer):
                continue
            fp = z.GetFilledPolysList(layer)
            for i in range(fp.OutlineCount()):
                o = fp.Outline(i)
                poly = Polygon([xy(o.CPoint(k)) for k in range(o.PointCount())])
                inter = poly.buffer(0).intersection(zpoly).area
                if inter > 1e-4:
                    # holes of the filled polygon can make the outline overlap without copper; subtract holes
                    holes = [Polygon([xy(fp.Hole(i, h).CPoint(k)) for k in range(fp.Hole(i, h).PointCount())])
                             for h in range(fp.HoleCount(i))]
                    for hp in holes:
                        inter -= hp.buffer(0).intersection(zpoly).area
                    if inter > 1e-3:
                        intruders.append(f"{z.GetNetname()} fill in {n} ({inter:.2f} mm2)")
check(not intruders, "no copper, pad, via or part inside the antenna keep-out or its 15 mm side clearance"
      + (f": {intruders[:6]}" if intruders else ""))
zl = next(b for n, b in zones if n == "U4 antenna keep-out")
check(abs(zl[3] - mb.H) < 0.01, f"antenna keep-out reaches the bottom edge (y {zl[1]:.1f}-{zl[3]:.1f} mm)")

# ---- 6. USB-C at the edge ---------------------------------------------------------------
j1 = fps["J1"]
fab = [it for it in j1.GraphicalItems() if it.GetLayer() == pcbnew.F_Fab and isinstance(it, pcbnew.FP_SHAPE)]
front = max(max(xy(it.GetStart())[1], xy(it.GetEnd())[1]) for it in fab)
check(abs(front - mb.H) < 0.01, f"USB-C body front at y = {front:.2f} mm = bottom board edge (70.00): opening flush, nothing sticks out")
jx0, jx1 = cy["J1"][0], cy["J1"][2]
# The plug's moulded grip sits outside the board edge; keep the last 4 mm of board beside the
# connector mouth free so nothing inside the board can touch it.
near = [r for r in fps if r != "J1" and not r.startswith("H") and cy[r][2] > jx0 - 1.0
        and cy[r][0] < jx1 + 1.0 and cy[r][3] > mb.H - 4.0]
check(not near, "USB-C opening clear: no other part within 1 mm beside the connector in the last 4 mm before the edge"
      + (f": {near}" if near else ""))
check(abs(xy(j1.GetPosition())[0] - mb.USB_X) < 0.01 and abs(xy(fps["U4"].GetPosition())[0] - mb.ESP_X) < 0.01,
      f"USB-C centre x = {xy(j1.GetPosition())[0]:.1f} mm, ESP32 centre x = {xy(fps['U4'].GetPosition())[0]:.1f} mm (approved 90 / 40)")

# ---- 7. LED1 marker and arrows ---------------------------------------------------------
silk = [d for d in board.GetDrawings() if d.GetLayer() == pcbnew.F_SilkS]
hits, gaps = [], []
for d in silk:
    b = bbox(d)
    for r in fps:
        if r.startswith("H"):                  # round hole courtyard (3.45 mm radius)
            hx, hy = xy(fps[r].GetPosition())
            if rect_circle_gap(b, hx, hy, 3.45) < 0:
                hits.append((d.GetClass(), r))
        elif overlap(b, cy[r]):
            hits.append((d.GetClass(), r))
        for p in fps[r].Pads():
            pb = bbox(p)
            dx = max(pb[0] - b[2], b[0] - pb[2], 0)
            dy = max(pb[1] - b[3], b[1] - pb[3], 0)
            gaps.append(math.hypot(dx, dy))
    if b[1] < 0.3 or b[3] > mb.H - 0.3:
        hits.append((d.GetClass(), "board edge"))
texts = [d for d in silk if isinstance(d, pcbnew.PCB_TEXT)]
arrows = len([d for d in silk if isinstance(d, pcbnew.PCB_SHAPE)]) // 3
check(any(t.GetText() == "LED1" for t in texts) and arrows == 7,
      f"LED1 marker + {arrows - 1} letter-to-letter arrows + 1 start arrow on the front silkscreen")
check(not hits, f"all printed markings (LED1, arrows, button and test-pad labels) touch no part outline or "
      f"board edge; nearest pad {min(gaps):.2f} mm away" + (f": {hits[:5]}" if hits else ""))
th = min(TO(t.GetTextHeight()) for t in texts)
check(th >= 1.0, f"smallest printed text {th:.1f} mm high, lines >= 0.15 mm (JLCPCB minimums 1.0 / 0.15 mm)")
hidden = [r for r in leds if fps[r].Reference().IsVisible()]
check(not hidden, "all 107 LED reference labels hidden on the silkscreen (kept in the board data)")

# ---- 8. remaining components (placement step 4) ------------------------------------------
import xml.etree.ElementTree as ET  # noqa: E402
NET = os.environ.get("NETLIST")
if NET:
    want_refs = {c.get("ref") for c in ET.parse(NET).getroot().iter("comp")}
    missing = sorted(want_refs - set(fps))
    extra = sorted(set(fps) - want_refs)
    check(not missing, f"all {len(want_refs)} schematic components placed; board also has {len(extra)} mechanical "
          f"items ({', '.join(extra)})" + (f"; MISSING: {missing}" if missing else ""))


def pxy(ref, num):
    return xy(pad(fps[ref], num).GetPosition())


def cen(ref):
    return xy(fps[ref].GetPosition())


def dist(a, b):
    return math.hypot(a[0] - b[0], a[1] - b[1])


def gap(ra, na, rb, nb):
    """Edge-to-edge distance between pad `na` of `ra` and pad `nb` of `rb` (smallest over same-numbered pads)."""
    best = 1e9
    for pa in fps[ra].Pads():
        if pa.GetNumber() != na:
            continue
        for pb in fps[rb].Pads():
            if pb.GetNumber() != nb:
                continue
            a_, b_ = bbox(pa), bbox(pb)
            best = min(best, math.hypot(max(a_[0] - b_[2], b_[0] - a_[2], 0), max(a_[1] - b_[3], b_[1] - a_[3], 0)))
    return best


if "U1" in fps:
    usb = {"U1 D- in (pin 4) to J1 D- (B7)": gap("U1", "4", "J1", "B7"),
           "U1 D+ in (pin 6) to J1 D+ (B6)": gap("U1", "6", "J1", "B6"),
           "D1 to U2 IN": gap("D1", "1", "U2", "4"), "C1 to U2 IN": gap("C1", "1", "U2", "4")}
    check(usb["U1 D- in (pin 4) to J1 D- (B7)"] <= 2.0 and usb["U1 D+ in (pin 6) to J1 D+ (B6)"] <= 2.0,
          "USB ESD chip right above the USB-C data pins; input parts at the eFuse (pad-to-pad gaps): "
          + ", ".join(f"{r} {d:.2f} mm" for r, d in usb.items()))
    ef = {"C1 (IN cap)": gap("C1", "1", "U2", "4"), "C2 (dVdt)": gap("C2", "1", "U2", "1"),
          "R5 (EN)": gap("R5", "1", "U2", "2"), "R4 (EN)": gap("R4", "2", "R5", "1"),
          "R3 (ILM)": gap("R3", "1", "U2", "7")}
    # TI SLVSE57 11.1 names CIN, RILM, CdVdT and the EN/UVLO resistors; the FLT pull-up is not critical
    ef_note = f"; R6 (FLT pull-up, not critical) {gap('R6', '2', 'U2', '6'):.2f} mm"
    check(max(ef.values()) <= 1.5, "eFuse parts next to their U2 pins (pad-to-pad gaps): "
          + ", ".join(f"{k} {v:.2f} mm" for k, v in ef.items()) + ef_note)
    hk = min(rect_circle_gap(cy[r], *xy(fps[h].GetPosition()), mb.HOLE_KEEPOUT_R)
             for h in fps if h.startswith("H") for r in fps if r.startswith("TP"))
    check(hk >= 1.0, f"test pads at least 1 mm outside the screw/washer keep-outs (closest {hk:.2f} mm)")
    rg = {"U3 to ESP32 3V3 pin": dist(cen("U3"), pxy("U4", "3")), "C7 to U3 IN": dist(cen("C7"), pxy("U3", "6")),
          "C8 to U3 OUT": dist(cen("C8"), pxy("U3", "1")), "C9 to ESP32 3V3": dist(cen("C9"), pxy("U4", "3")),
          "C10 to ESP32 3V3": dist(cen("C10"), pxy("U4", "3")), "C11/R7 to EN": dist(cen("C11"), pxy("U4", "8"))}
    # C9 (10 uF bulk) sits 6 mm away so the USB pair can pass south of it; the 100 nF (C10) is at the pin
    check(rg["U3 to ESP32 3V3 pin"] <= 10 and rg["C9 to ESP32 3V3"] <= 6.5
          and max(v for k, v in rg.items() if k.startswith("C") and k != "C9 to ESP32 3V3") <= 4.0,
          "regulator and ESP32 capacitors close: " + ", ".join(f"{k} {v:.1f} mm" for k, v in rg.items()))
    ls = {"U5 output to R13": dist(pxy("U5", "4"), cen("R13")), "R13 to LED1 data-in": dist(cen("R13"), pxy("LED1", "4")),
          "R12 to U5 input": dist(cen("R12"), pxy("U5", "2")), "C12 to U5 VCC": dist(cen("C12"), pxy("U5", "5"))}
    check(ls["U5 output to R13"] + ls["R13 to LED1 data-in"] <= 15,
          "short 5 V LED data path next to LED1: " + ", ".join(f"{k} {v:.1f} mm" for k, v in ls.items()))
    cd = [(dist(pxy(f"C{100 + n}", "1"), pxy(f"LED{n}", "1")), n) for n in range(1, 108)]
    same_net = all(pad(fps[f"C{100 + n}"], "1").GetNetname() == "+5V" and pad(fps[f"C{100 + n}"], "2").GetNetname() == "GND"
                   for n in range(1, 108))
    check(same_net and max(cd)[0] <= 3.0, f"107 LED capacitors, each beside its own LED: +5V pad to LED VDD pad "
          f"average {sum(d for d, _ in cd) / 107:.1f} mm, worst {max(cd)[0]:.1f} mm (LED{max(cd)[1]})")
    labels = {t.GetText() for t in texts}
    check({"RESET", "BOOT", "ON/OFF"} <= labels, "button labels RESET, BOOT, ON/OFF printed under SW1, SW2, SW3")
    tpn = ["5V_IN", "5V_OUT", "3V3", "GND", "LED_DIN", "FLT", "ILM", "RX", "TX"]
    tps = [r for r in fps if r.startswith("TP")]
    tgap = min(dist(cen(a), cen(b)) for i, a in enumerate(tps) for b in tps[i + 1:])
    check(set(tpn) <= labels and len(tps) == 10 and tgap >= 3.0,
          f"10 test pads (1.5 mm) all labelled; closest two are {tgap:.1f} mm apart (room for probe tips)")
    bx = {r: round(cen(r)[0], 1) for r in ("C301", "C302", "C303", "C304")}
    fitted = {r: not (fps[r].GetAttributes() & pcbnew.FP_EXCLUDE_FROM_POS_FILES) for r in bx}
    check(fitted == {"C301": True, "C302": False, "C303": False, "C304": True},
          "bulk capacitors along the LED rail at x = " + ", ".join(f"{r} {x} mm ({'fitted' if fitted[r] else 'DNP'})"
                                                                  for r, x in bx.items()))
    dnp_ok = all(fps[r].GetAttributes() & pcbnew.FP_EXCLUDE_FROM_POS_FILES for r in ["C13", "C14"] + tps)
    check(dnp_ok, "DNP capacitors (C13, C14, C302, C303) and bare test pads excluded from JLCPCB's parts/placement files")

print("\n".join(lines))
if errors:
    print("\nPROBLEMS:")
    for e in errors:
        print("  " + e)
    sys.exit(1)
print("\nAll placement checks passed.")