board
RITHESH LED Display
Public Unreviewedby Rithesh03
A 300 x 70 mm PCB that spells RITHESH with 107 WS2812B RGB LEDs, controlled over Wi-Fi.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
"""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.")