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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
"""Placement step: ESP32, USB-C and the 107 LEDs (run after make_board.py).
Usage: python3 place_parts.py <netlist.xml exported from the schematic>
- Each placed footprint gets the schematic reference, value, symbol link (path) and the
schematic nets on its pads, exactly as KiCad's "Update PCB from Schematic" would.
- LED order: LED1..LED107 in the schematic's letter order (R 1-18, I 19-29, T 30-40,
H 41-57, E 58-75, S 76-90, H 91-107). Inside each letter the chain snakes row by row;
letters alternate top-down / bottom-up so each letter ends next to the next letter's start.
- Each LED is rotated (0/90/180/270) so its data-out pad faces the next LED and its
data-in pad faces the previous one.
"""
import math
import os
import sys
import xml.etree.ElementTree as ET
import pcbnew
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import make_board as mb # noqa: E402 (shared board geometry)
HW = mb.HW
MM = pcbnew.FromMM
TO = pcbnew.ToMM
LIBS = {"rithesh_fp": os.path.join(HW, "rithesh_fp.pretty")}
STD_FP = "/usr/share/kicad/footprints" # KiCad's standard libraries (${KICAD7_FOOTPRINT_DIR})
def lib_path(nick):
return LIBS.get(nick) or os.path.join(STD_FP, nick + ".pretty")
EDGE_SILK_LIMIT = mb.H - 0.3 # silkscreen must end this far inside the bottom edge
ESP_Y = mb.H - mb.ESP_H / 2 # module centre so its antenna end is on the bottom edge
USB_Y = mb.H - 3.65 # connector body front (3.65 mm from its centre) on the edge
# ---- chain order ------------------------------------------------------------------------
def chain():
"""[(ref, letter_index, letter, x, y)] in LED1..LED107 order."""
out, n = [], 0
for i, ch in enumerate(mb.WORD):
rows = list(range(7)) if i % 2 == 0 else list(range(6, -1, -1))
for k, r in enumerate(rows):
cols = [c for c, bit in enumerate(mb.LETTERS[ch][r]) if bit == "1"]
if k % 2 == 1:
cols.reverse()
for c in cols:
n += 1
out.append((f"LED{n}", i, ch, mb.COL0_X + (i * 6 + c) * mb.PITCH, mb.ROW0_Y + r * mb.PITCH))
return out
# ---- netlist ----------------------------------------------------------------------------
def read_netlist(path):
root = ET.parse(path).getroot()
comps = {}
for c in root.iter("comp"):
sp = c.find("sheetpath").get("tstamps")
comps[c.get("ref")] = {"value": c.findtext("value"), "footprint": c.findtext("footprint"),
"path": sp + c.findtext("tstamps")}
pad_net = {}
for net in root.iter("net"):
for node in net.iter("node"):
pad_net[(node.get("ref"), node.get("pin"))] = net.get("name")
return comps, pad_net
class Placer:
def __init__(self, board, comps, pad_net):
self.b, self.comps, self.pad_net, self.nets = board, comps, pad_net, {}
self.placed = {} # ref -> footprint (KiCad's own list can return raw objects mid-run)
def net(self, name):
if name not in self.nets:
ni = pcbnew.NETINFO_ITEM(self.b, name)
self.b.Add(ni)
self.nets[name] = ni
return self.nets[name]
def place(self, ref, x, y, rot=0):
c = self.comps[ref]
lib, name = c["footprint"].split(":")
fp = pcbnew.FootprintLoad(lib_path(lib), name)
fp.SetFPIDAsString(c["footprint"])
fp.SetReference(ref)
fp.SetValue(c["value"])
fp.SetPath(pcbnew.KIID_PATH(c["path"]))
self.b.Add(fp)
fp.SetOrientationDegrees(rot)
fp.SetPosition(mb.pt(x, y))
for pad in fp.Pads():
key = (ref, pad.GetNumber())
if key in self.pad_net:
pad.SetNet(self.net(self.pad_net[key]))
self.placed[ref] = fp
return fp
def pad_xy(fp, num):
for p in fp.Pads():
if p.GetNumber() == num:
v = p.GetPosition()
return TO(v.x) - mb.X0, TO(v.y) - mb.Y0
def unit(dx, dy):
d = math.hypot(dx, dy)
return (dx / d, dy / d) if d else (0.0, 0.0)
def best_rotation(fp, x, y, prev, nxt):
"""Try 0/90/180/270; keep the one where DOUT (pin 2) faces the next LED and
DIN (pin 4) faces the previous one (largest worst-case alignment)."""
best = None
for rot in (0, 90, 180, 270):
fp.SetOrientationDegrees(rot)
dout, din = pad_xy(fp, "2"), pad_xy(fp, "4")
scores = []
if nxt:
u = unit(nxt[0] - x, nxt[1] - y)
scores.append((dout[0] - x) * u[0] + (dout[1] - y) * u[1])
if prev:
u = unit(prev[0] - x, prev[1] - y)
scores.append((din[0] - x) * u[0] + (din[1] - y) * u[1])
s = min(scores)
if best is None or s > best[0] + 1e-9:
best = (s, rot)
fp.SetOrientationDegrees(best[1])
return best[1]
# ---- silkscreen helpers -------------------------------------------------------------------
def trim_edge_silk(fp):
"""Clip footprint silkscreen lines that reach the bottom board edge; drop text beyond it."""
limit = MM(mb.Y0 + EDGE_SILK_LIMIT)
removed, clipped = 0, 0
for item in list(fp.GraphicalItems()):
if item.GetLayer() != pcbnew.F_SilkS:
continue
if isinstance(item, pcbnew.FP_SHAPE) and item.GetShape() == pcbnew.SHAPE_T_SEGMENT:
a, b = item.GetStart(), item.GetEnd()
if a.y > limit and b.y > limit:
fp.Remove(item)
removed += 1
continue
for get, set_ in ((item.GetStart, item.SetStart), (item.GetEnd, item.SetEnd)):
p = get()
if p.y > limit:
o = b if get == item.GetStart else a
t = (limit - o.y) / (p.y - o.y)
set_(pcbnew.VECTOR2I(int(o.x + (p.x - o.x) * t), limit))
clipped += 1
item.SetLocalCoord()
return removed, clipped
def silk_arrow(board, x0, y, x1):
for (a, b) in [((x0, y), (x1, y)), ((x1, y), (x1 - 1.0 * math.copysign(1, x1 - x0), y - 0.7)),
((x1, y), (x1 - 1.0 * math.copysign(1, x1 - x0), y + 0.7))]:
s = mb.shape(board, pcbnew.SHAPE_T_SEGMENT, pcbnew.F_SilkS, 0.22)
s.SetStart(mb.pt(*a))
s.SetEnd(mb.pt(*b))
def rule_rect(board, x0, y0, x1, y1, name):
z = pcbnew.ZONE(board)
z.SetIsRuleArea(True)
z.SetZoneName(name)
layers = pcbnew.LSET()
layers.AddLayer(pcbnew.F_Cu)
layers.AddLayer(pcbnew.B_Cu)
z.SetLayerSet(layers)
for flag in (z.SetDoNotAllowCopperPour, z.SetDoNotAllowTracks, z.SetDoNotAllowVias,
z.SetDoNotAllowPads, z.SetDoNotAllowFootprints):
flag(True)
o = z.Outline()
o.NewOutline()
for (x, y) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]:
p = mb.pt(x, y)
o.Append(p.x, p.y)
board.Add(z)
# ---- remaining components -----------------------------------------------------------------
# (x, y, rotation) in board mm from the top-left corner. Grouped next to the pins they serve.
SUPPORT = {
# USB-C input: the ESD chip sits straight above the data pins (about 2 mm of unprotected track, no
# branches); D+ and D- flow through it (pins 4->3 and 6->1). CC pull-downs beside the CC lines.
"U1": (90.2, 58.2, 270), "R2": (92.9, 57.6, 0), "R1": (95.4, 57.2, 90), "TP1": (98.8, 58.6, 0),
# eFuse (TI SLVSE57 section 11.1): IN capacitor and surge diode right at the IN pins, dVdt capacitor,
# EN/UVLO divider and ILM resistor each next to its own pin, FLT pull-up beside FLT
"U2": (108.0, 63.0, 0), "C1": (105.5, 64.3, 270), "D1": (100.9, 64.3, 180),
"C2": (106.9, 60.6, 90), "R4": (105.5, 60.5, 270), "R5": (105.2, 62.05, 180),
"R3": (109.8, 60.7, 90), "R6": (111.3, 60.7, 270),
"TP8": (108.5, 58.2, 0), "TP7": (113.8, 61.4, 0), "TP2": (113.5, 66.3, 0), "TP4": (118.2, 66.3, 0),
# 3.3 V regulator + ESP32 support on the module's right side (3V3, EN and IO2 pins)
"U3": (53.5, 60.5, 0), "C7": (57.5, 60.5, 90), "C8": (50.9, 60.3, 90),
"C9": (50.3, 56.9, 90), "C10": (48.3, 61.4, 0), "R10": (48.3, 59.8, 0),
"C11": (48.3, 58.0, 0), "R7": (48.3, 56.2, 180), "TP3": (53.8, 56.6, 0),
# buttons in a row between the regulator and the USB-C connector, 9 mm apart: RESET, ON/OFF, BOOT.
# This order lets the ESP32's top-edge lines end one after another without crossing; the three
# pull-ups sit in the gaps between / above the buttons, fed with 3.3 V from a track under the buttons.
"SW1": (62.3, 59.2, 0), "SW3": (71.3, 59.2, 0), "SW2": (80.3, 59.2, 0),
"R11": (66.8, 57.3, 90), "R9": (75.8, 57.3, 90), "R8": (80.3, 55.4, 90),
# module's left side: USB series resistors next to the USB pins (+ DNP caps), UART
"R15": (31.1, 55.4, 0), "R14": (31.1, 56.6, 0), "C14": (32.6, 54.2, 90), "C13": (32.6, 57.8, 270),
"R16": (31.4, 60.9, 180),
"TP9": (10.0, 66.5, 0), "TP10": (13.5, 66.5, 0), "TP5": (17.0, 66.5, 0),
# level shifter right next to LED1 (left margin): short 5 V data path to the first LED;
# TP6 kept more than 1 mm outside the screw-head keep-out
"TP6": (4.4, 9.3, 0), "R13": (4.4, 12.1, 90), "U5": (4.4, 16.8, 90), "C12": (4.4, 20.4, 90),
"R12": (4.4, 22.6, 90),
# bulk capacitors along the LED power rail: left end, one-third, two-thirds, right end
"C301": (13.5, 58.0, 0), "C302": (140.0, 60.5, 0), "C303": (212.0, 60.5, 0), "C304": (283.0, 60.5, 0),
}
TP_NAMES = {"TP1": "5V_IN", "TP2": "5V_OUT", "TP3": "3V3", "TP4": "GND", "TP5": "GND", "TP6": "LED_DIN",
"TP7": "FLT", "TP8": "ILM", "TP9": "RX", "TP10": "TX"}
BUTTON_LABELS = {"SW1": "RESET", "SW2": "BOOT", "SW3": "ON/OFF"}
SHOW_REFS = {"U1", "U2", "U3", "U5", "D1", "J1"}
REF_AT = {"U1": (87.2, 56.9), "U3": (53.5, 58.4)} # moved clear of neighbouring markings # other small parts: names kept off the silkscreen
TP_LABEL_AT = {"TP6": (-2.7, 1.6, 90, "center")} # (dx, dy, angle, align)
def rect_of(f):
"""Courtyard bounding box (board mm), measured from the footprint's F.CrtYd drawings."""
xs, ys = [], []
for it in f.GraphicalItems():
if it.GetLayer() == pcbnew.F_CrtYd:
b = it.GetBoundingBox()
xs += [b.GetLeft(), b.GetRight()]
ys += [b.GetTop(), b.GetBottom()]
return (TO(min(xs)) - mb.X0, TO(min(ys)) - mb.Y0, TO(max(xs)) - mb.X0, TO(max(ys)) - mb.Y0)
def hits(r, rects, circles, margin=0.1):
for q in rects:
if not (r[2] + margin <= q[0] or q[2] + margin <= r[0] or r[3] + margin <= q[1] or q[3] + margin <= r[1]):
return True
for (cx, cy, rad) in circles:
dx = max(r[0] - cx, 0, cx - r[2])
dy = max(r[1] - cy, 0, cy - r[3])
if math.hypot(dx, dy) < rad + margin:
return True
return r[0] < 0.5 or r[1] < 0.5 or r[2] > mb.W - 0.5 or r[3] > mb.H - 0.5
def place_support(P, board, order):
fps = dict(P.placed)
for ref, (x, y, rot) in SUPPORT.items():
f = P.place(ref, x, y, rot)
f.Reference().SetVisible(ref in SHOW_REFS)
if ref in REF_AT:
f.Reference().SetTextAngleDegrees(0)
f.Reference().SetPosition(mb.pt(*REF_AT[ref]))
fps[ref] = f
fps["U4"].Reference().SetVisible(False) # no free space beside the module for a readable "U4"
# Not assembled by JLCPCB: DNP capacitor spots and bare copper test pads. KiCad 7 boards have
# no DNP flag, so these are excluded from the BOM and the pick-and-place (position) files.
skip = pcbnew.FP_EXCLUDE_FROM_BOM | pcbnew.FP_EXCLUDE_FROM_POS_FILES
for ref in ["C13", "C14", "C302", "C303"] + list(TP_NAMES):
fps[ref].SetAttributes(fps[ref].GetAttributes() | skip)
for ref in ("C302", "C303"):
x, y, _ = SUPPORT[ref]
mb.label(board, "DNP", x, y - 5.0, layer=pcbnew.F_SilkS, size=1.2, thick=0.18, align="center")
for ref, name in TP_NAMES.items():
x, y, _ = SUPPORT[ref]
dx, dy, ang, align = TP_LABEL_AT.get(ref, (0.0, -2.3, 0, "center"))
t = mb.label(board, name, x + dx, y + dy, layer=pcbnew.F_SilkS, size=1.0, thick=0.15, align=align)
t.SetTextAngleDegrees(ang)
for ref, name in BUTTON_LABELS.items():
x, y, _ = SUPPORT[ref]
mb.label(board, name, x, y + 4.4, layer=pcbnew.F_SilkS, size=1.2, thick=0.18, align="center")
# obstacles for the LED capacitors: every courtyard, hole keep-outs, antenna areas, silk marks
rects = [rect_of(f) for r, f in fps.items() if not r.startswith("H")]
circles = [(hx, hy, mb.HOLE_KEEPOUT_R) for hx, hy in mb.HOLES]
for z in board.Zones():
if z.GetIsRuleArea():
b = z.GetBoundingBox()
rects.append((TO(b.GetLeft()) - mb.X0, TO(b.GetTop()) - mb.Y0, TO(b.GetRight()) - mb.X0, TO(b.GetBottom()) - mb.Y0))
for r, f in fps.items(): # LED pin-1 dots sit just outside the LED courtyards
if r.startswith("LED"):
for it in f.GraphicalItems():
if it.GetLayer() == pcbnew.F_SilkS and it.GetShape() == pcbnew.SHAPE_T_CIRCLE:
b = it.GetBoundingBox()
rects.append((TO(b.GetLeft()) - mb.X0, TO(b.GetTop()) - mb.Y0,
TO(b.GetRight()) - mb.X0, TO(b.GetBottom()) - mb.Y0))
for d in board.GetDrawings():
if d.GetLayer() == pcbnew.F_SilkS:
b = d.GetBoundingBox()
rects.append((TO(b.GetLeft()) - mb.X0, TO(b.GetTop()) - mb.Y0, TO(b.GetRight()) - mb.X0, TO(b.GetBottom()) - mb.Y0))
# one 100 nF capacitor per LED, in the nearest free gap beside that LED's +5V pad.
# Candidate outlines are computed from the 0402 footprint's courtyard and pad-1 offset for
# each rotation (measured once), so only the chosen position is actually placed.
probe_board = pcbnew.BOARD()
probe = pcbnew.FootprintLoad(lib_path("Capacitor_SMD"), "C_0402_1005Metric")
probe_board.Add(probe)
geo = {}
for rot in (0, 90, 180, 270):
probe.SetOrientationDegrees(rot)
probe.SetPosition(pcbnew.VECTOR2I(0, 0))
xs, ys = [], []
for it in probe.GraphicalItems():
if it.GetLayer() == pcbnew.F_CrtYd:
b = it.GetBoundingBox()
xs += [TO(b.GetLeft()), TO(b.GetRight())]
ys += [TO(b.GetTop()), TO(b.GetBottom())]
p1 = [q for q in probe.Pads() if q.GetNumber() == "1"][0].GetPosition()
geo[rot] = ((min(xs), min(ys), max(xs), max(ys)), (TO(p1.x), TO(p1.y)))
def overlap(r, q, margin=0.1):
return not (r[2] + margin <= q[0] or q[2] + margin <= r[0] or r[3] + margin <= q[1] or q[3] + margin <= r[1])
tight = []
cands = [] # per LED: [(distance, x, y, rot, rect)], nearest first
for k, (ref, li, ch, x, y) in enumerate(order):
led = fps[ref]
vdd = pad_xy(led, "1")
half_x, half_y = (3.1, 2.8) if round(led.GetOrientationDegrees()) % 180 == 0 else (2.8, 3.1)
spots = []
for side in (-1, 1):
for s_ in (0.0, -0.6, 0.6, -1.2, 1.2, -1.8, 1.8, -2.4, 2.4):
spots.append((x + s_, y + side * (half_y + 0.47 + 0.25), 0)) # horizontal, above / below
spots.append((x + side * (half_x + 0.47 + 0.25), y + s_, 90)) # vertical, left / right
# "tight" spots: the capacitor courtyard only 0.07-0.12 mm from the LED courtyards (still not
# overlapping; bodies stay ~0.5 mm apart). Used only for capacitors that would otherwise be > 3 mm away.
tight_spots = []
for e in (0.12, 0.07):
for side in (-1, 1):
for s_ in (0.0, -0.6, 0.6, -1.2, 1.2, -1.8, 1.8, -2.4, 2.4):
tight_spots.append((x + s_, y + side * (half_y + 0.47 + e), 0))
tight_spots.append((x + side * (half_x + 0.47 + e), y + s_, 90))
def evaluate(spot_list, margin):
opts = []
for (cx, cy, crot) in spot_list:
best = None
for r2 in (crot, crot + 180): # pin 1 (+5V) towards the LED's VDD pad
(bx0, by0, bx1, by1), (px, py) = geo[r2 % 360]
r = (cx + bx0, cy + by0, cx + bx1, cy + by1)
d = math.hypot(cx + px - vdd[0], cy + py - vdd[1])
if not hits(r, rects, circles, margin) and (best is None or d < best[0]):
best = (d, cx, cy, r2 % 360, r)
if best:
opts.append(best)
return sorted(opts)
cands.append(evaluate(spots, 0.1))
tight.append(evaluate(tight_spots, 0.02))
chosen = {}
for k in range(len(order)): # first pass: nearest free spot, in chain order
for c in cands[k]:
if not any(overlap(c[4], o[4]) for o in chosen.values()):
chosen[k] = c
break
FAR = 3.0
for k in sorted(chosen, key=lambda k: -chosen[k][0]): # second pass: fix capacitors > 3 mm away
if chosen[k][0] <= FAR:
continue
for c in cands[k]:
if c[0] > FAR:
break
blockers = [j for j, o in chosen.items() if j != k and overlap(c[4], o[4])]
if len(blockers) != 1:
continue
j = blockers[0]
others = [o for i, o in chosen.items() if i not in (j, k)]
alt = next((a for a in cands[j] if a[0] <= FAR and not overlap(a[4], c[4])
and not any(overlap(a[4], o[4]) for o in others)), None)
if alt:
chosen[j], chosen[k] = alt, c
break
tight_used = []
for k in sorted(chosen, key=lambda k: -chosen[k][0]): # third pass: tight spot for any still > 3 mm
if chosen[k][0] <= FAR:
continue
others = [o for i, o in chosen.items() if i != k]
t = next((c for c in tight[k] if c[0] < chosen[k][0] - 1.0
and not any(overlap(c[4], o[4], 0.02) for o in others)), None)
if t:
tight_used.append((order[k][0], round(chosen[k][0], 1), round(t[0], 1)))
chosen[k] = t
print("LED capacitors moved into tight spots (LED, old mm, new mm):", tight_used)
unplaced = []
for k in range(len(order)):
cref = f"C{101 + k}"
if k not in chosen:
unplaced.append(cref)
continue
d, cx, cy, crot, r = chosen[k]
f = P.place(cref, cx, cy, crot)
f.Reference().SetVisible(False)
far = sorted(((round(chosen[k][0], 1), order[k][0]) for k in chosen if chosen[k][0] > FAR), reverse=True)
print("LED capacitors farther than 3 mm from their LED's VDD pad:", far)
print("support parts placed:", len(SUPPORT), "| LED capacitors placed:", len(order) - len(unplaced),
"| not placed:", unplaced)
# ---- main -------------------------------------------------------------------------------
def main():
comps, pad_net = read_netlist(sys.argv[1])
board = pcbnew.LoadBoard(mb.PCB)
P = Placer(board, comps, pad_net)
# ESP32: rotated 180 deg so the antenna end is on the bottom edge
u4 = P.place("U4", mb.ESP_X, ESP_Y, 180)
u4.Reference().SetPosition(mb.pt(mb.ESP_X + mb.ESP_W / 2 + 2.5, ESP_Y - 5.0))
u4.Reference().SetTextAngleDegrees(0)
print("U4 silkscreen: removed %d, clipped %d lines at the edge" % trim_edge_silk(u4))
# antenna side clearance (Espressif >= 15 mm each side), both copper layers, nothing allowed
ex0, ex1 = mb.ESP_X - mb.ESP_W / 2 - 0.2, mb.ESP_X + mb.ESP_W / 2 + 0.2 # start at U4 courtyard
rule_rect(board, ex0 - (mb.ANT_SIDE - 0.2), mb.H - mb.ANT_H, ex0, mb.H, "Antenna clearance left")
rule_rect(board, ex1, mb.H - mb.ANT_H, ex1 + (mb.ANT_SIDE - 0.2), mb.H, "Antenna clearance right")
# outline drawings so the keep-outs show up in plots (User.Drawings, not manufactured)
for (x0, x1) in [(ex0 - (mb.ANT_SIDE - 0.2), ex0), (ex0 + 0.2, ex1 - 0.2), (ex1, ex1 + (mb.ANT_SIDE - 0.2))]:
mb.rect(board, x0, mb.H - mb.ANT_H, x1, mb.H, pcbnew.Dwgs_User, 0.15)
for x in (ex0 - (mb.ANT_SIDE - 0.2) + 0.8, ex1 + 0.8):
mb.label(board, "15 mm clearance", x, mb.H - mb.ANT_H + 1.8, size=1.0)
mb.label(board, "(no copper/parts)", x, mb.H - mb.ANT_H + 3.4, size=1.0)
# USB-C: opening faces the bottom edge, body front exactly on the edge
j1 = P.place("J1", mb.USB_X, USB_Y, 0)
j1.Reference().SetPosition(mb.pt(mb.USB_X - mb.USB_W / 2 - 2.2, USB_Y + 0.2))
print("J1 silkscreen: removed %d, clipped %d lines at the edge" % trim_edge_silk(j1))
# LEDs
order = chain()
rots = {}
for k, (ref, li, ch, x, y) in enumerate(order):
prev = order[k - 1][3:5] if k > 0 else (x - mb.PITCH, y) # LED1 is fed from the left
nxt = order[k + 1][3:5] if k + 1 < len(order) else None
fp = P.place(ref, x, y, 0)
rots[ref] = best_rotation(fp, x, y, prev, nxt)
fp.Reference().SetVisible(False) # decision: LED names hidden on silkscreen
# data-chain path drawing (User.Comments, not manufactured) and letter-to-letter arrows
for k in range(len(order) - 1):
s = mb.shape(board, pcbnew.SHAPE_T_SEGMENT, pcbnew.Cmts_User, 0.25)
s.SetStart(mb.pt(*order[k][3:5]))
s.SetEnd(mb.pt(*order[k + 1][3:5]))
arrows = []
for k in range(len(order) - 1):
a, b = order[k], order[k + 1]
if a[1] != b[1]: # letter change
gap_x = mb.COL0_X + (a[1] * 6 + 5) * mb.PITCH
silk_arrow(board, gap_x - 3.0, a[4], gap_x + 3.0)
arrows.append((a[2], b[2], gap_x, a[4]))
place_support(P, board, order)
pcbnew.SaveBoard(mb.PCB, board)
with open(os.path.join(HW, "tools", "led_chain.csv"), "w") as f:
f.write("ref,letter,x_mm,y_mm,rotation_deg\n")
for (ref, li, ch, x, y) in order:
f.write(f"{ref},{ch},{x:.1f},{y:.1f},{rots[ref]}\n")
print("placed", len(order), "LEDs; arrows:", arrows)
if __name__ == "__main__":
main()