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
"""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()