main
John Lauer README: component table per the wiki format, open-source section, no duplicate hero; generators at gen/ 39a59ef 7d ago
"""Silkscreen layout for the riser (run with KiCad 10's python.exe).

Reads the saved board, builds per-face obstacles (pads with their mask openings, vias, holes, part courtyards, the
board edge, the OCP long-edge strips, the finger field) and places, greedily with a bounded search of positions,
rotations and sizes measured with KiCad's own stroke-font bounds:
  - every reference (and every R/C value beside it), test-point net labels, port and LED legends on F.SilkS,
  - finger pin numbers at the ends of each finger group on both faces,
  - the board identity, link and the BIOS note on B.SilkS.
Writes design/silk_plan.json (field updates by native field UUID, standalone texts) and, with "apply", a copy of the
board with the plan applied for DRC (design/silk_check.kicad_pcb). Every number in the plan is from this board."""
import json, os, sys, math
import pcbnew

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SRC = sys.argv[1]
MODE = sys.argv[2] if len(sys.argv) > 2 else "plan"
MM, TO = pcbnew.FromMM, pcbnew.ToMM
b = pcbnew.LoadBoard(SRC)

STROKE = 0.15          # JLC pack: min text thickness 0.15 mm
REF_SIZES = (1.0, 0.8)  # JLC pack: min text height 0.8 mm
VAL_SIZES = (0.8,)
CLR = 0.15             # silk to exposed copper / other silk

KEYED = {"R11", "R12", "R13", "R14", "R15", "R16", "R17", "R18"}   # PERST# series 33R and pull-down 100k block
TP_LABEL = {"TP1": "3V3", "TP2": "GND", "TP3": "SMCLK", "TP4": "SMDAT", "TP5": "12V", "TP6": "PWRGD",
            "TP7": "PERST0", "TP8": "PERST1", "TP9": "PERST2", "TP10": "PERST3", "TP11": "SCLK", "TP12": "SDOUT"}
LED_LABEL = {"D1": "3V3", "D2": "12V"}


def box(x0, y0, x1, y1):
    return (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))


def grow(r, d):
    return (r[0] - d, r[1] - d, r[2] + d, r[3] + d)


def hit(r, s):
    return not (r[2] <= s[0] or s[2] <= r[0] or r[3] <= s[1] or s[3] <= r[1])


def bb(item):
    e = item.GetBoundingBox()
    return box(TO(e.GetLeft()), TO(e.GetTop()), TO(e.GetRight()), TO(e.GetBottom()))


# ------------------------------------------------------------------ obstacles per face
obst = {"F": [], "B": []}
for fp in b.GetFootprints():
    for side, lay in (("F", pcbnew.F_CrtYd), ("B", pcbnew.B_CrtYd)):
        cy = fp.GetCourtyard(lay)
        if cy.OutlineCount():
            e = cy.BBox()
            r = box(TO(e.GetLeft()), TO(e.GetTop()), TO(e.GetRight()), TO(e.GetBottom()))
            if fp.GetReference() != "J1":           # J1's courtyard is the finger field, handled below
                obst[side].append(("crtyd", fp.GetReference(), r))
    for p in fp.Pads():
        r = grow(bb(p), CLR + 0.05)                 # mask expansion + clearance
        if p.IsOnLayer(pcbnew.F_Cu) or p.GetAttribute() in (pcbnew.PAD_ATTRIB_PTH, pcbnew.PAD_ATTRIB_NPTH):
            obst["F"].append(("pad", fp.GetReference(), r))
        if p.IsOnLayer(pcbnew.B_Cu) or p.GetAttribute() in (pcbnew.PAD_ATTRIB_PTH, pcbnew.PAD_ATTRIB_NPTH):
            obst["B"].append(("pad", fp.GetReference(), r))
# vias are tented on both faces (board setup), so silk may cross them; only exposed copper is an obstacle
W, H = 76.0, 115.0
for side in ("F", "B"):
    obst[side] += [("edge", "", (-1, -1, 3.05, H + 1)), ("edge", "", (W - 3.05, -1, W + 1, H + 1)),   # strips + margin
                   ("edge", "", (-1, -1, W + 1, 0.4)), ("fingers", "", (-1, 111.7, W + 1, H + 1))]

placed = {"F": [], "B": []}


def free(side, r, ignore=()):
    if r[0] < 0.4 or r[2] > W - 0.4 or r[1] < 0.4 or r[3] > H - 0.4:
        return False
    for kind, ref, s in obst[side]:
        if ref in ignore and kind == "crtyd":
            continue
        if hit(r, s):
            return False
    return not any(hit(grow(r, CLR / 2), grow(s, CLR / 2)) for s in placed[side])


def text_box(text, x, y, size, rot, side, align="center"):
    t = pcbnew.PCB_TEXT(b)
    t.SetText(text); t.SetTextSize(pcbnew.VECTOR2I(MM(size), MM(size))); t.SetTextThickness(MM(STROKE))
    t.SetPosition(pcbnew.VECTOR2I(MM(x), MM(y))); t.SetTextAngleDegrees(rot)
    t.SetLayer(pcbnew.F_SilkS if side == "F" else pcbnew.B_SilkS)
    t.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_CENTER if align == "center" else pcbnew.GR_TEXT_H_ALIGN_LEFT)
    if side == "B":
        t.SetMirrored(True)
    return bb(t)


def search(text, cx, cy, around, side, sizes, ignore=(), prefer=None):
    """Positions on a ring around `around` (a box), nearest first, at each size and rotation."""
    best = None
    for size in sizes:
        for rot in (0, 90):
            for gap in (0.2, 0.5, 0.9, 1.4, 2.0, 2.8):
                cands = []
                x0, y0, x1, y1 = around
                mx, my = (x0 + x1) / 2, (y0 + y1) / 2
                tb = text_box(text, 0, 0, size, rot, side)
                hw, hh = (tb[2] - tb[0]) / 2, (tb[3] - tb[1]) / 2
                pts = []
                for k in range(-6, 7):          # slide along each side of the part
                    o = 0.35 * k
                    pts += [(mx + o, y0 - gap - hh), (mx + o, y1 + gap + hh), (x0 - gap - hw, my + o), (x1 + gap + hw, my + o)]
                for (px, py) in pts:
                    cands.append((math.hypot(px - cx, py - cy), px, py))
                if prefer:
                    cands.sort(key=lambda c: (c[0] + (0 if prefer(c[1], c[2]) else 50)))
                else:
                    cands.sort()
                for _, px, py in cands:
                    r = text_box(text, px, py, size, rot, side)
                    # centre the box on (px, py): KiCad positions by the text anchor, so shift
                    dx, dy = px - (r[0] + r[2]) / 2, py - (r[1] + r[3]) / 2
                    r = (r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy)
                    if free(side, r, ignore):
                        return dict(x=round(px + dx, 3), y=round(py + dy, 3), size=size, rot=rot, box=r)
    return best


plan = {"fields": [], "texts": [], "unresolved": []}
# labels that failed on an earlier pass go first (a bounded repack instead of a single greedy order)
_pri_path = os.path.join(ROOT, "design", "silk_priority.json")
PRI = json.load(open(_pri_path)) if os.path.exists(_pri_path) else []
fps = sorted(b.GetFootprints(), key=lambda f: (f.GetReference() not in PRI, PRI.index(f.GetReference()) if f.GetReference() in PRI else 0,
                                               f.GetReference()[0], f.GetPosition().y, f.GetPosition().x))

# ------------------------------------------------------------------ fixed legends first (they anchor the reading)
def add_text(text, x, y, size, side, rot=0, align="center", why=""):
    r = text_box(text, x, y, size, rot, side, align)
    placed[side].append(r)
    plan["texts"].append(dict(text=text, layer=f"{side}.SilkS", x=round(x, 3), y=round(y, 3), height=size,
                              stroke=STROKE, rotation=rot, align=align, why=why))


# finger group ends, both faces: B pins on top, A pins below (A under B)
J1 = b.FindFootprintByReference("J1")
jx, jy = TO(J1.GetPosition().x), TO(J1.GetPosition().y)
ends = [("OCP_B1", -30.400), ("OCP_B14", -30.400 + 13 * 0.6), ("B1", -18.205), ("B28", -2.005), ("B29", 2.005),
        ("B42", 9.805), ("B43", 14.715), ("B70", 30.915)]
for k, (name, dx) in enumerate(ends):
    # first pin of a group: text runs inward (right) from the finger; last pin: inward (left); clear of the slots
    for side, nm in (("F", name), ("B", name.replace("B", "A"))):
        r = text_box(nm, 0, 0, 0.8, 0, side)
        half = (r[2] - r[0]) / 2
        x = jx + dx + (half - 0.19 if k % 2 == 0 else -(half - 0.19))
        yy = 109.75 if name == "OCP_B14" else 110.9      # the short OCP group: its two labels on two rows
        add_text(nm, x, yy, 0.8, side, why=f"finger pin number at the {'start' if k % 2 == 0 else 'end'} of its group ({'top = B' if side == 'F' else 'bottom = A'} side)")

# the ports on the bottom face too (a mounted card may show only its underside)
for _k, _r in enumerate(("J2", "J3", "J4", "J5")):
    _fp = b.FindFootprintByReference(_r)
    _x = TO(_fp.GetPosition().x)
    for _y in (12.0, 12.8, 13.6, 14.4, 15.2):
        if free("B", text_box(f"PORT {_k}", _x, _y, 1.0, 0, "B")):
            add_text(f"PORT {_k}", _x, _y, 1.0, "B", why=f"{_r} port number, bottom face")
            break
    else:
        plan["unresolved"].append(f"{_r}: bottom-face port legend found no space")

# ------------------------------------------------------------------ references, values, labels
for fp in fps:
    ref = fp.GetReference()
    if ref in ("J1", "H1", "H2"):
        continue
    side = "B" if fp.IsFlipped() else "F"
    cy_ = fp.GetCourtyard(pcbnew.F_CrtYd if side == "F" else pcbnew.B_CrtYd)
    e = cy_.BBox() if cy_.OutlineCount() else fp.GetBoundingBox()
    around = box(TO(e.GetLeft()), TO(e.GetTop()), TO(e.GetRight()), TO(e.GetBottom()))
    cx, cy = (around[0] + around[2]) / 2, (around[1] + around[3]) / 2
    label = ref
    if ref in TP_LABEL:
        label = f"{ref} {TP_LABEL[ref]}"
    elif ref in LED_LABEL:
        label = f"{ref} {LED_LABEL[ref]}"
    elif ref == "JP1":
        label = "JP1 FRU WRITE"
    elif ref[0] == "J" and ref in ("J2", "J3", "J4", "J5"):
        label = f"{ref} PORT {int(ref[1]) - 2}"
    # the connectors face the faceplate: their labels go on the board side of the ESD parts
    prefer = (lambda x, y: y > 11.5) if ref in ("J2", "J3", "J4", "J5") else None
    got = search(label, cx, cy, around, side, REF_SIZES, ignore=(ref,), prefer=prefer)
    split = None
    if not got and label != ref:
        got = search(ref, cx, cy, around, side, REF_SIZES, ignore=(ref,))
        if got:
            split = label[len(ref) + 1:]       # the function goes beside the reference, as its own text
            label = ref
    if not got:
        plan["unresolved"].append(f"{ref}: no free spot for its reference at 0.8 mm")
        continue
    placed[side].append(got["box"])
    if label == ref:
        f = fp.Reference()
        plan["fields"].append(dict(ref=ref, field="Reference", id=f.m_Uuid.AsString(), x=got["x"], y=got["y"],
                                   rotation=got["rot"], layer=f"{side}.SilkS", height=got["size"], stroke=STROKE,
                                   visible=True))
    else:
        add_text(label, got["x"], got["y"], got["size"], side, got["rot"], why=f"{ref} with its function")
        placed[side].pop()   # add_text appended the same box again
    if split:
        sg = search(split, got["x"], got["y"], got["box"], side, (0.8,), ignore=(ref,))
        if sg:
            add_text(split, sg["x"], sg["y"], sg["size"], side, sg["rot"], why=f"{ref} function, beside its reference")
        else:
            plan["unresolved"].append(f"{ref}: its function '{split}' has no spot beside the reference")
    # the value beside the reference, for R and C (the dense PERST# resistor block gets one key instead)
    if ref[0] in "RC" and ref not in KEYED:
        val = fp.GetValue()
        vg = search(val, got["x"], got["y"], got["box"], side, VAL_SIZES, ignore=(ref,))
        if vg:
            placed[side].append(vg["box"])
            f = fp.Value()
            plan["fields"].append(dict(ref=ref, field="Value", id=f.m_Uuid.AsString(), x=vg["x"], y=vg["y"],
                                       rotation=vg["rot"], layer=f"{side}.SilkS", height=vg["size"], stroke=STROKE,
                                       visible=True))
        else:
            plan["unresolved"].append(f"{ref}: value {val} has no free spot beside its reference (in the bottom key)")

# one value key for the PERST# resistor block, as near the block as it fits
_xs = [TO(b.FindFootprintByReference(r).GetPosition().x) for r in KEYED]; _ys = [TO(b.FindFootprintByReference(r).GetPosition().y) for r in KEYED]
_blk = (min(_xs) - 0.6, min(_ys) - 0.4, max(_xs) + 0.6, max(_ys) + 0.4)
for _txt in ("R11-R14 33  R15-R18 100k", "R11-14 33 / R15-18 100k"):
    kg = search(_txt, (_blk[0] + _blk[2]) / 2, (_blk[1] + _blk[3]) / 2, _blk, "F", (0.8,))
    if kg:
        add_text(_txt, kg["x"], kg["y"], 0.8, "F", kg["rot"], why="values of the PERST# resistor block (33 ohm series, 100k pull-down)")
        break
else:
    # under the block on the bottom face (mirrored, reads from below), still beside the parts it names
    for _txt in ("R11-R14 33  R15-R18 100k", "R11-14 33 / R15-18 100k"):
        kg = search(_txt, (_blk[0] + _blk[2]) / 2, (_blk[1] + _blk[3]) / 2, grow(_blk, -1.0), "B", (0.8,))
        if kg:
            add_text(_txt, kg["x"], kg["y"], 0.8, "B", kg["rot"], why="values of the PERST# resistor block, under it on the bottom face")
            plan["unresolved"].append("R11-R18: values are keyed on the bottom face under the block (no top-side room)")
            break
    else:
        plan["unresolved"].append("R11-R18: the value key found no spot near the block")

# ------------------------------------------------------------------ bottom: identity, link, BIOS note, value key
ident = [("OCP NIC 3.0 SFF  >  4x OCuLink 4i", 1.6), ("PCIe Gen4 x16 as 4 x4   rev A", 1.2),
         ("Set the slot to x4x4x4x4 in BIOS", 1.0), ("wiki.adom.inc/john/oculink-aiom-riser", 1.0)]
y = 40.0
for text, size in ident:
    r = text_box(text, 38.0, y, size, 0, "B")
    while not free("B", r) and y < 100:
        y += 0.5; r = text_box(text, 38.0, y, size, 0, "B")
    if free("B", r):
        add_text(text, 38.0, y, size, "B", why="board identity / service note")
        y += size * 1.9
    else:
        plan["unresolved"].append(f"bottom: '{text}' found no space")
missing_vals = [u.split(":")[0] for u in plan["unresolved"] if "value " in u and b.FindFootprintByReference(u.split(":")[0]) is not None]
if missing_vals:
    for chunk_start in range(0, len(missing_vals), 6):
        chunk = missing_vals[chunk_start:chunk_start + 6]
        line = "  ".join(f"{r}={b.FindFootprintByReference(r).GetValue()}" for r in chunk)
        r = text_box(line, 38.0, y, 0.8, 0, "B")
        while not free("B", r) and y < 105:
            y += 0.5; r = text_box(line, 38.0, y, 0.8, 0, "B")
        if free("B", r):
            add_text(line, 38.0, y, 0.8, "B", why="value key for parts whose value did not fit beside them")
            y += 1.6

json.dump(plan, open(os.path.join(ROOT, "design", "silk_plan.json"), "w"), indent=1)
newpri = PRI + [u.split(":")[0] for u in plan["unresolved"] if u.split(":")[0] not in PRI and not u.startswith("bottom")]
json.dump(newpri, open(_pri_path, "w"))
print(f"fields {len(plan['fields'])}, texts {len(plan['texts'])}, unresolved {len(plan['unresolved'])}")
for u in plan["unresolved"]:
    print("  unresolved:", u)

if MODE == "apply":
    for fl in plan["fields"]:
        fp = b.FindFootprintByReference(fl["ref"])
        f = fp.Reference() if fl["field"] == "Reference" else fp.Value()
        f.SetVisible(True)
        f.SetLayer(pcbnew.F_SilkS if fl["layer"] == "F.SilkS" else pcbnew.B_SilkS)
        f.SetTextSize(pcbnew.VECTOR2I(MM(fl["height"]), MM(fl["height"]))); f.SetTextThickness(MM(fl["stroke"]))
        f.SetTextAngleDegrees(fl["rotation"])
        f.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_CENTER)
        f.SetPosition(pcbnew.VECTOR2I(MM(fl["x"]), MM(fl["y"])))
    for tx in plan["texts"]:
        t = pcbnew.PCB_TEXT(b)
        t.SetText(tx["text"]); t.SetTextSize(pcbnew.VECTOR2I(MM(tx["height"]), MM(tx["height"])))
        t.SetTextThickness(MM(tx["stroke"])); t.SetPosition(pcbnew.VECTOR2I(MM(tx["x"]), MM(tx["y"])))
        t.SetTextAngleDegrees(tx["rotation"])
        t.SetLayer(pcbnew.F_SilkS if tx["layer"] == "F.SilkS" else pcbnew.B_SilkS)
        t.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_CENTER if tx["align"] == "center" else pcbnew.GR_TEXT_H_ALIGN_LEFT)
        if tx["layer"] == "B.SilkS":
            t.SetMirrored(True)
        b.Add(t)
    out = os.path.join(ROOT, "design", "silk_check.kicad_pcb")
    pcbnew.SaveBoard(out, b)
    print("applied ->", out)