← Commit history
README.md+32−18
@@ -1,12 +1,7 @@ # OCuLink Riser for the Supermicro AIOM Slot -::adom-video{src="/john/oculink-aiom-riser/render/video/oculink-riser.video.json"}--<p align="center"><img src="docs/renders/hero-iso.png" width="900" alt="The riser in 3D: gold fingers at the front, four OCuLink receptacles on the faceplate edge"></p>- A passive card for Supermicro's AIOM slot (OCP NIC 3.0 small form factor) that breaks the PCIe x16 link out to four OCuLink (SFF-8611) ports, so NVMe drives or a GPU can be cabled out at PCIe Gen4. It was built for a YouTube viewer who asked for one, by Claude in Adom Hydrogen with adom-aiflow and KiCad. - **Status: designed and checked, not yet built.** The KiCad project, Gerbers, drill files, BOM and pick-and-place are all here. Nobody has fabricated or tested a card yet. If you build one, please open an issue on this page with what you found.  ## What it does@@ -33,18 +28,27 @@ A passive card for Supermicro's AIOM slot (OCP NIC 3.0 small form factor) that b  ## Components -| | Part | Ref | What it does |-|---|---|---|---|-| <img src="docs/parts/g14a42111c12hr.png" width="64"> | [Amphenol G14A42111C12HR](https://wiki.adom.inc/adom/g14a42111c12hr) | J2-J5 | OCuLink 4X right-angle receptacle, one per x4 port |-| <img src="docs/parts/sn74lv165apwr.png" width="64"> | [TI SN74LV165APWR](https://wiki.adom.inc/adom/sn74lv165apwr) | U1 | OCP scan chain: the host reads the card's present and status bits |-| <img src="docs/parts/br24h64fj-5ace2.png" width="64"> | [ROHM BR24H64FJ-5ACE2](https://wiki.adom.inc/adom/br24h64fj-5ace2) | U2 | 64 kbit FRU EEPROM on the slot's SMBus |-| <img src="docs/parts/tps3808g33dbvr.png" width="64"> | [TI TPS3808G33](https://wiki.adom.inc/adom/tps3808g33dbvr) | U3 | NIC_PWR_GOOD supervisor (ordered as TPS3808G33DBVTG4) |-| <img src="docs/parts/sn74lvc125apwr.png" width="64"> | [TI SN74LVC125APWR](https://wiki.adom.inc/adom/sn74lvc125apwr) | U4 | buffers each port's PERST# |-| <img src="docs/parts/tpd4e05u06qdqarq1.png" width="64"> | [TI TPD4E05U06QDQARQ1](https://wiki.adom.inc/adom/tpd4e05u06qdqarq1) | U5-U8 | ESD protection on each port's sideband pins |-| <img src="docs/parts/in-s63btg.png" width="64"> | [Inolux IN-S63BTG](https://wiki.adom.inc/adom/in-s63btg) | D1, D2 | green LEDs: 3.3 V aux and 12 V present |-| <img src="docs/parts/r0402.png" width="64"> | [33 ohm](https://wiki.adom.inc/adom/33-0402-resistors), [100 ohm](https://wiki.adom.inc/adom/100-0402-resistors), [1k](https://wiki.adom.inc/adom/1k-0402-resistors), [4.7k](https://wiki.adom.inc/adom/4-7k-0402-resistors), [10k](https://wiki.adom.inc/adom/10k-0402-resistors), [100k](https://wiki.adom.inc/adom/100k-0402-resistors) 0402 | R1-R24 | straps, pull-ups and pull-downs, PERST# series resistors |-| <img src="docs/parts/c0402.png" width="64"> | [100 nF 0402](https://wiki.adom.inc/adom/100n-0402-capacitors) | C1-C4 | decoupling |-| <img src="docs/parts/c0603.png" width="64"> | [10 uF 0603](https://wiki.adom.inc/adom/10u-0603-capacitors) | C5 | 3.3 V aux bulk |+Every part links to its Adom component page (symbol, footprint, 3D model, provenance).++| Part | Qty | Refs | Value | Role | Buy |+|---|---|---|---|---|---|+| <a href="https://wiki.adom.inc/adom/100n-0402-capacitors"><img src="docs/parts/c0402.png?v=c0c2a785" width="64" alt="CL05B104KO5NNNC"></a><br>**[CL05B104KO5NNNC](https://wiki.adom.inc/adom/100n-0402-capacitors)**<br>Samsung Electro-Mechanics | 4 | C1, C2, C3, C4 | 100n | decoupling | [Mouser 187-CL05B104KO5NNNC](https://www.mouser.com/ProductDetail/187-CL05B104KO5NNNC) |+| <a href="https://wiki.adom.inc/adom/10u-0603-capacitors"><img src="docs/parts/c0603.png?v=aff404fd" width="64" alt="CL10A106KQ8NNNC"></a><br>**[CL10A106KQ8NNNC](https://wiki.adom.inc/adom/10u-0603-capacitors)**<br>Samsung Electro-Mechanics | 1 | C5 | 10u | 3.3 V aux bulk | [Mouser 187-CL10A106KQ8NNNC](https://www.mouser.com/ProductDetail/187-CL10A106KQ8NNNC) |+| <a href="https://wiki.adom.inc/adom/in-s63btg"><img src="docs/parts/in-s63btg.png?v=9f467354" width="64" alt="IN-S63BTG"></a><br>**[IN-S63BTG](https://wiki.adom.inc/adom/in-s63btg)**<br>Inolux | 2 | D1, D2 | GREEN | 3.3 V aux / 12 V present LEDs | [Mouser 743-IN-S63BTG](https://www.mouser.com/ProductDetail/743-IN-S63BTG) |+| <a href="https://wiki.adom.inc/adom/g14a42111c12hr"><img src="docs/parts/g14a42111c12hr.png?v=af4a94bd" width="64" alt="G14A42111C12HR"></a><br>**[G14A42111C12HR](https://wiki.adom.inc/adom/g14a42111c12hr)**<br>Amphenol Commercial Products | 4 | J2, J3, J4, J5 | G14A42111C12HR | OCuLink 4X receptacle, one per x4 port | [Mouser 523-G14A42111C12HR](https://www.mouser.com/ProductDetail/523-G14A42111C12HR) |+| <a href="https://wiki.adom.inc/adom/100-0402-resistors"><img src="docs/parts/r0402.png?v=b0e9fb72" width="64" alt="RC0402FR-07100RL"></a><br>**[RC0402FR-07100RL](https://wiki.adom.inc/adom/100-0402-resistors)**<br>YAGEO | 2 | R1, R2 | 100 | present-pin straps (PRSNTB2#/3#) | [Mouser 603-RC0402FR-07100RL](https://www.mouser.com/ProductDetail/603-RC0402FR-07100RL) |+| <a href="https://wiki.adom.inc/adom/1k-0402-resistors"><img src="docs/parts/r0402.png?v=b0e9fb72" width="64" alt="RC0402FR-071KL"></a><br>**[RC0402FR-071KL](https://wiki.adom.inc/adom/1k-0402-resistors)**<br>YAGEO | 3 | R3, R6, R23 | 1k | scan-chain pull-ups, LED | [Mouser 603-RC0402FR-071KL](https://www.mouser.com/ProductDetail/603-RC0402FR-071KL) |+| <a href="https://wiki.adom.inc/adom/10k-0402-resistors"><img src="docs/parts/r0402.png?v=b0e9fb72" width="64" alt="RC0402FR-0710KL"></a><br>**[RC0402FR-0710KL](https://wiki.adom.inc/adom/10k-0402-resistors)**<br>YAGEO | 4 | R4, R5, R8, R9 | 10k | pull-ups/downs | [Mouser 603-RC0402FR-0710KL](https://www.mouser.com/ProductDetail/603-RC0402FR-0710KL) |+| <a href="https://wiki.adom.inc/adom/100k-0402-resistors"><img src="docs/parts/r0402.png?v=b0e9fb72" width="64" alt="RC0402FR-07100KL"></a><br>**[RC0402FR-07100KL](https://wiki.adom.inc/adom/100k-0402-resistors)**<br>YAGEO | 5 | R7, R15, R16, R17, R18 | 100k | PERST# pull-downs, supervisor | [Mouser 603-RC0402FR-07100KL](https://www.mouser.com/ProductDetail/603-RC0402FR-07100KL) |+| <a href="https://wiki.adom.inc/adom/33-0402-resistors"><img src="docs/parts/r0402.png?v=b0e9fb72" width="64" alt="RC0402FR-0733RL"></a><br>**[RC0402FR-0733RL](https://wiki.adom.inc/adom/33-0402-resistors)**<br>YAGEO | 4 | R11, R12, R13, R14 | 33 | PERST# series resistors | [Mouser 603-RC0402FR-0733RL](https://www.mouser.com/ProductDetail/603-RC0402FR-0733RL) |+| <a href="https://wiki.adom.inc/adom/4-7k-0402-resistors"><img src="docs/parts/r0402.png?v=b0e9fb72" width="64" alt="RC0402FR-074K7L"></a><br>**[RC0402FR-074K7L](https://wiki.adom.inc/adom/4-7k-0402-resistors)**<br>YAGEO | 1 | R24 | 4.7k | 12 V LED resistor | [Mouser 603-RC0402FR-074K7L](https://www.mouser.com/ProductDetail/603-RC0402FR-074K7L) |+| <a href="https://wiki.adom.inc/adom/sn74lv165apwr"><img src="docs/parts/sn74lv165apwr.png?v=e08abfd9" width="64" alt="SN74LV165APWRE4"></a><br>**[SN74LV165APWRE4](https://wiki.adom.inc/adom/sn74lv165apwr)**<br>Texas Instruments | 1 | U1 | SN74LV165APWR | OCP scan chain (present and status bits) | [Mouser 595-SN74LV165APWRE4](https://www.mouser.com/ProductDetail/595-SN74LV165APWRE4) |+| <a href="https://wiki.adom.inc/adom/br24h64fj-5ace2"><img src="docs/parts/br24h64fj-5ace2.png?v=a351dc3b" width="64" alt="BR24H64FJ-5ACE2"></a><br>**[BR24H64FJ-5ACE2](https://wiki.adom.inc/adom/br24h64fj-5ace2)**<br>ROHM Semiconductor | 1 | U2 | BR24H64FJ-5ACE2 | FRU EEPROM on the slot SMBus | [Mouser 755-BR24H64FJ-5ACE2](https://www.mouser.com/ProductDetail/755-BR24H64FJ-5ACE2) |+| <a href="https://wiki.adom.inc/adom/tps3808g33dbvr"><img src="docs/parts/tps3808g33dbvr.png?v=f779c707" width="64" alt="TPS3808G33DBVTG4"></a><br>**[TPS3808G33DBVTG4](https://wiki.adom.inc/adom/tps3808g33dbvr)**<br>Texas Instruments | 1 | U3 | TPS3808G33 | NIC_PWR_GOOD supervisor | [Mouser 595-TPS3808G33DBVTG4](https://www.mouser.com/ProductDetail/595-TPS3808G33DBVTG4) |+| <a href="https://wiki.adom.inc/adom/sn74lvc125apwr"><img src="docs/parts/sn74lvc125apwr.png?v=c911c8a9" width="64" alt="SN74LVC125APWR"></a><br>**[SN74LVC125APWR](https://wiki.adom.inc/adom/sn74lvc125apwr)**<br>Texas Instruments | 1 | U4 | SN74LVC125APWR | buffers each port's PERST# | [Mouser 595-SN74LVC125APWR](https://www.mouser.com/ProductDetail/595-SN74LVC125APWR) |+| <a href="https://wiki.adom.inc/adom/tpd4e05u06qdqarq1"><img src="docs/parts/tpd4e05u06qdqarq1.png?v=c2f8b9a5" width="64" alt="TPD4E05U06QDQARQ1"></a><br>**[TPD4E05U06QDQARQ1](https://wiki.adom.inc/adom/tpd4e05u06qdqarq1)**<br>Texas Instruments | 4 | U5, U6, U7, U8 | TPD4E05U06 | ESD on each port's sideband | [Mouser 595-PD4E05U06QDQARQ1](https://www.mouser.com/ProductDetail/595-PD4E05U06QDQARQ1) |++No-part features (the gold-finger edge J1, faceplate holes H1/H2, test pads TP1-TP12, solder jumper JP1) are copper on the board.  ## How it was designed @@ -62,9 +66,19 @@ The full engineering record is in [design/README.md](design/README.md): the spec |---|---| | `kicad/` | the KiCad 10 project: board, schematic, project library, 3D models, design rules | | `fab/` | `gerbers.zip` (Gerbers + Excellon drill), `bom.csv`, `cpl.csv`, and `export-check.json` (every file re-read against the board) |-| `design/` | the design record, calculations, test plan, FRU image and the generator scripts |+| `design/` | the design record, calculations, test plan and FRU image |+| `gen/` | the generator scripts | | `docs/` | renders, the current/heat figures, part icons, and the aiflow run record (`docs/runs/`) | +## Open source++Everything that made this board is in this page's **Files** tab: the KiCad 10 project in `kicad/`, and the Python that generated it in `gen/` (netlist, footprints from the OCP and Amphenol drawings, outline, placement, the high-speed router with lane reversal and delay matching, the maze router for the slow nets, silkscreen, export).++1. **Get it**: `adom-wiki repo clone john/oculink-aiom-riser`, then open `kicad/ocp-oculink-riser.kicad_pro` in KiCad 10.+2. **Rebuild it**: `gen/run_pipeline.sh` regenerates the schematic, board and checks from `gen/design.py`; `gen/export_fab.py` writes and re-checks the Gerbers, BOM and CPL. The scripts call KiCad 10's `kicad-cli` and `python.exe` from a Windows install (paths at the top of each script); the container-side ones need `shapely`, `scipy` and `numpy`.+3. **Change it with an AI**: open the folder in Claude Code and describe what you want, for example "route port 3 to a vertical OCuLink receptacle", "add a REFCLK buffer", or "make a 2-port 4C version".+4. **Send it back**: open a pull request on this page, or start a discussion with what you built and what you measured.+ ## License  Open hardware: the board files, the design record and the scripts are CC-BY-4.0. Build it, change it, sell it; credit this page.
design/README.md+1−1
@@ -96,4 +96,4 @@ Order the board with JLCPCB's impedance control on JLC06161H-3313 and give them | `tests.json` | the bench test plan | | `fru.bin`, `fru.txt` | the FRU EEPROM image and its decode | | `bom.csv` | the bill of materials with stock checked 2026-10-12 |-| `gen/` | the generators: netlist, footprints, outline, placement, high-speed router, maze router, finishing, board build, schematic |+| `../gen/` | the generators: netlist, footprints, outline, placement, high-speed router, maze router, finishing, board build, schematic |
gen/build_board.pyadded+207
@@ -0,0 +1,207 @@+"""Run with KiCad 10's python.exe. Stage 'base': footprints, nets, outline -> board + pads.json.+Stage 'final': base + tracks/vias/zones from routes.json, zones filled, saved."""+import json, os, sys, math+import pcbnew++HERE = os.path.dirname(os.path.abspath(__file__))+ROOT = os.path.dirname(HERE)+STD_FP = r"C:\Users\john\AppData\Local\Programs\KiCad\10.0\share\kicad\footprints"+PRJ_FP = os.path.join(ROOT, "kicad", "lib")+OUT = os.path.join(ROOT, "kicad", "ocp-oculink-riser.kicad_pcb")+STAGE = sys.argv[1] if len(sys.argv) > 1 else "base"++nl = json.load(open(os.path.join(ROOT, "design", "netlist.json")))+lay = json.load(open(os.path.join(ROOT, "design", "layout.json")))+MM = pcbnew.FromMM+++def V(x, y):+    return pcbnew.VECTOR2I(MM(x), MM(y))+++LAYERS = {"L1": pcbnew.F_Cu, "L3": pcbnew.In2_Cu, "L5": pcbnew.In4_Cu, "L6": pcbnew.B_Cu,+          "L2": pcbnew.In1_Cu, "L4": pcbnew.In3_Cu}+++def new_board():+    b = pcbnew.BOARD()+    b.SetCopperLayerCount(6)+    ds = b.GetDesignSettings()+    ds.SetBoardThickness(MM(1.6))+    # JLCPCB 6-layer standard process minimums (adom/pcb-design-rules jlcpcb-6L)+    ds.m_MinClearance = MM(0.09); ds.m_TrackMinWidth = MM(0.09)+    ds.m_ViasMinSize = MM(0.25); ds.m_ViasMinAnnularWidth = MM(0.05)+    ds.m_MinThroughDrill = MM(0.15); ds.m_HoleToHoleMin = MM(0.2)+    ds.m_HoleClearance = MM(0.12); ds.m_CopperEdgeClearance = MM(0.2)+    try:+        nc = ds.m_NetSettings.GetDefaultNetclass()+    except Exception:+        nc = None+    if nc is not None:+        nc.SetClearance(MM(0.12)); nc.SetTrackWidth(MM(0.15))+        nc.SetViaDiameter(MM(0.4)); nc.SetViaDrill(MM(0.2))+        try:+            nc.SetDiffPairWidth(MM(0.163)); nc.SetDiffPairGap(MM(0.15))+        except Exception:+            pass+    b.SetLayerName(pcbnew.In1_Cu, "In1.Cu")+    # nets+    nets = {}+    ncp = os.path.join(ROOT, "design", "nc_nets.json")+    nc = json.load(open(ncp)) if os.path.exists(ncp) else {}+    for name in sorted(set(nl["nets"]) | set(nc.values())):+        ni = pcbnew.NETINFO_ITEM(b, name)+        b.Add(ni)+        nets[name] = ni+    pin_net = {}+    for name, pins in nl["nets"].items():+        for p in pins:+            pin_net[p] = name+    pin_net.update(nc)+    # footprints+    for c in nl["components"]:+        lib, fpname = c["fp"].split(":")+        libdir = os.path.join(PRJ_FP, lib + ".pretty") if lib == "Riser" else os.path.join(STD_FP, lib + ".pretty")+        fp = pcbnew.FootprintLoad(libdir, fpname)+        if fp is None:+            raise SystemExit("cannot load " + c["fp"])+        fp.SetReference(c["ref"])+        fp.SetValue(c["value"])+        fp.SetFPIDAsString(c["fp"])+        for k, v in (("MPN", c.get("mpn", "")), ("Manufacturer", c.get("mfr", "")), ("Source", c.get("src", "")),+                     ("Description", c.get("desc", ""))):+            fp.SetField(k, v or "")+            f_ = fp.GetField(k)+            if f_ is not None:+                f_.SetVisible(False)+        # silk: the silkscreen step places every reference; until then they are hidden. Chip R/C keep no body silk+        # use the project's no-silk copies (Riser:*_NoSilk); ICs, LEDs and connectors keep their pin-1 / polarity marks.+        fp.Reference().SetVisible(False)+        x, y, rot, side = lay["placement"][c["ref"]]+        b.Add(fp)+        fp.SetPosition(V(x, y))+        fp.SetOrientationDegrees(rot)+        if side == "B":+            fp.Flip(V(x, y), pcbnew.FLIP_DIRECTION_TOP_BOTTOM)+        for pad in fp.Pads():+            key = f'{c["ref"]}.{pad.GetNumber()}'+            if key in pin_net:+                pad.SetNet(nets[pin_net[key]])+        # 3D model binding+        if c.get("model"):+            fp.Models().clear()+            m = pcbnew.FP_3DMODEL()+            m.m_Filename = c["model"]+            fp.Models().push_back(m)+    # outline: lines and arcs (every corner filleted)+    for e in lay["edges"]:+        s = pcbnew.PCB_SHAPE(b)+        if e[0] == "line":+            s.SetShape(pcbnew.SHAPE_T_SEGMENT)+            s.SetStart(V(e[1], e[2])); s.SetEnd(V(e[3], e[4]))+        else:+            s.SetShape(pcbnew.SHAPE_T_ARC)+            s.SetArcGeometry(V(e[1], e[2]), V(e[3], e[4]), V(e[5], e[6]))+        s.SetLayer(pcbnew.Edge_Cuts); s.SetWidth(MM(0.05))+        b.Add(s)+    return b, nets+++def export_pads(b):+    out = []+    for fp in b.GetFootprints():+        for pad in fp.Pads():+            sz = pad.GetSize(pcbnew.F_Cu)+            lset = [pcbnew.BOARD.GetStandardLayerName(l) for l in pad.GetLayerSet().CuStack()]+            poly = pad.GetEffectivePolygon(pcbnew.F_Cu if pad.IsOnLayer(pcbnew.F_Cu) else pcbnew.B_Cu, pcbnew.ERROR_INSIDE)+            outline = []+            for oi in range(poly.OutlineCount()):+                ol = poly.Outline(oi)+                outline.append([(pcbnew.ToMM(ol.CPoint(i).x), pcbnew.ToMM(ol.CPoint(i).y)) for i in range(ol.PointCount())])+            dr = pad.GetDrillSize()+            out.append(dict(ref=fp.GetReference(), num=pad.GetNumber(), net=pad.GetNetname(),+                            x=pcbnew.ToMM(pad.GetPosition().x), y=pcbnew.ToMM(pad.GetPosition().y),+                            w=pcbnew.ToMM(sz.x), h=pcbnew.ToMM(sz.y), layers=lset, poly=outline,+                            drill=[pcbnew.ToMM(dr.x), pcbnew.ToMM(dr.y)], attr=str(pad.GetAttribute())))+    return out+++def add_routes(b, nets, which="routes.json"):+    r = json.load(open(os.path.join(ROOT, "design", which)))+    for (lay_, net, x1, y1, x2, y2, w) in r["tracks"]:+        t = pcbnew.PCB_TRACK(b)+        t.SetStart(V(x1, y1)); t.SetEnd(V(x2, y2)); t.SetWidth(MM(w))+        t.SetLayer(LAYERS[lay_]); t.SetNet(nets[net])+        b.Add(t)+    for (x, y, net, d, h) in r["vias"]:+        v = pcbnew.PCB_VIA(b)+        v.SetPosition(V(x, y)); v.SetWidth(MM(d)); v.SetDrill(MM(h))+        v.SetViaType(pcbnew.VIATYPE_THROUGH)+        v.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)+        v.SetNet(nets[net])+        b.Add(v)+    for z in r.get("zones", []):+        zone = pcbnew.ZONE(b)+        zone.SetLayer(LAYERS[z["layer"]] if z["layer"] in LAYERS else getattr(pcbnew, z["layer"]))+        if z.get("keepout"):+            zone.SetIsRuleArea(True)+            zone.SetDoNotAllowTracks(z.get("no_tracks", True)); zone.SetDoNotAllowVias(z.get("no_vias", True))+            zone.SetDoNotAllowZoneFills(z.get("no_pour", True)); zone.SetDoNotAllowPads(False)+            zone.SetDoNotAllowFootprints(False)+            if z.get("layers"):+                ls = pcbnew.LSET()+                for ln in z["layers"]:+                    ls.AddLayer(LAYERS[ln])+                zone.SetLayerSet(ls)+        else:+            zone.SetNet(nets[z["net"]])+            zone.SetMinThickness(MM(0.15))+            zone.SetLocalClearance(MM(z.get("clearance", 0.2)))+            zone.SetAssignedPriority(z.get("priority", 0))+            zone.SetPadConnection(pcbnew.ZONE_CONNECTION_THERMAL if z.get("thermal", True) else pcbnew.ZONE_CONNECTION_FULL)+            zone.SetThermalReliefGap(MM(0.25)); zone.SetThermalReliefSpokeWidth(MM(0.3))+            zone.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_ALWAYS)+        ol = zone.Outline()+        ol.NewOutline()+        for (x, y) in z["poly"]:+            ol.Append(MM(x), MM(y))+        b.Add(zone)+    for t in r.get("texts", []):+        tx = pcbnew.PCB_TEXT(b)+        tx.SetText(t["text"]); tx.SetPosition(V(t["x"], t["y"]))+        tx.SetLayer(getattr(pcbnew, t["layer"].replace(".", "_")))+        tx.SetTextSize(V(t["size"], t["size"])); tx.SetTextThickness(MM(t["size"] * 0.15))+        if t.get("angle"):+            tx.SetTextAngleDegrees(t["angle"])+        if t["layer"].startswith("B."):+            tx.SetMirrored(True)+        b.Add(tx)+++if __name__ == "__main__":+    b, nets = new_board()+    if STAGE == "base":+        json.dump(export_pads(b), open(os.path.join(ROOT, "design", "pads.json"), "w"))+        pcbnew.SaveBoard(OUT.replace(".kicad_pcb", "-base.kicad_pcb"), b)+        print("base ok", len(b.GetFootprints()), "footprints")+    elif STAGE == "start":+        # the run's start board: interface parts in place, the rest staged beside the outline, live copper held back+        st = json.load(open(os.path.join(ROOT, "design", "routes_start.json")))+        for fp in b.GetFootprints():+            ref = fp.GetReference()+            if ref in st["stage"]:+                x, y, rot, side = st["stage"][ref]+                fp.SetPosition(V(x, y)); fp.SetOrientationDegrees(rot)+        add_routes(b, nets, "routes_start.json")+        filler = pcbnew.ZONE_FILLER(b)+        filler.Fill(b.Zones())+        out = os.path.join(ROOT, "aiflow", "start", "ocp-oculink-riser.kicad_pcb")+        os.makedirs(os.path.dirname(out), exist_ok=True)+        pcbnew.SaveBoard(out, b)+        print("start ok", len(b.GetTracks()), "items")+    else:+        add_routes(b, nets)+        filler = pcbnew.ZONE_FILLER(b)+        filler.Fill(b.Zones())+        pcbnew.SaveBoard(OUT, b)+        print("final ok", len(b.GetTracks()), "track/via items,", b.GetAreaCount(), "zones")
gen/check.pyadded+76
@@ -0,0 +1,76 @@+#!/usr/bin/env python3+"""Render and clearance-check the routing plan (container side, before KiCad)."""+import sys, os, json, math+sys.path.insert(0, os.path.dirname(__file__))+import matplotlib+matplotlib.use("Agg")+import matplotlib.pyplot as plt+from shapely.geometry import LineString, Point+from shapely.strtree import STRtree++COL = {"L1": "#d62728", "L3": "#2ca02c", "L5": "#1f77b4", "L6": "#9467bd"}+++def shapes(tracks, vias):+    """list of (layerset, net, geom)"""+    out = []+    for (lay, net, x1, y1, x2, y2, w) in tracks:+        out.append(({lay}, net, LineString([(x1, y1), (x2, y2)]).buffer(w / 2, cap_style=1), ("t", lay, x1, y1, x2, y2)))+    for (x, y, net, d, h) in vias:+        out.append(({"L1", "L3", "L5", "L6"}, net, Point(x, y).buffer(d / 2), ("v", x, y)))+    return out+++def clearance(items, clr=0.15, extra=()):+    geoms = [g for (_, _, g, _) in items]+    tree = STRtree(geoms)+    bad = []+    for i, (ls, net, g, tag) in enumerate(items):+        for j in tree.query(g.buffer(clr)):+            if j <= i:+                continue+            ls2, net2, g2, tag2 = items[j]+            if net2 == net or not (ls & ls2):+                continue+            dd = g.distance(g2)+            if dd < clr - 1e-6:+                bad.append((round(dd, 3), net, net2, sorted(ls & ls2)[0], tag[:4], tag2[:4]))+    return bad+++def render(tracks, vias, path, outline=None, extra_polys=(), xlim=(0, 76), ylim=(115, 0), layers=("L1", "L3", "L5", "L6"), dpi=110):+    fig, ax = plt.subplots(figsize=(8, 12))+    if outline:+        xs, ys = zip(*outline)+        ax.plot(xs, ys, "k-", lw=0.6)+    for (lay, net, x1, y1, x2, y2, w) in tracks:+        if lay in layers:+            ax.plot([x1, x2], [y1, y2], "-", color=COL[lay], lw=max(0.3, w * 3), solid_capstyle="round", alpha=0.85)+    for (x, y, net, d, h) in vias:+        ax.add_patch(plt.Circle((x, y), d / 2, color="#444", lw=0, alpha=0.8))+    for poly, c in extra_polys:+        xs, ys = zip(*poly)+        ax.fill(xs, ys, color=c, alpha=0.35, lw=0)+    ax.set_xlim(*xlim); ax.set_ylim(*ylim); ax.set_aspect("equal")+    ax.grid(True, lw=0.2)+    fig.savefig(path, dpi=dpi, bbox_inches="tight")+    plt.close(fig)+++if __name__ == "__main__":+    import hs+    pairs, lines = hs.all_routes()+    T = [t for p in pairs + lines for t in p.tracks]+    V = [v for p in pairs + lines for v in p.vias]+    bad = clearance(shapes(T, V), 0.15)+    print(len(bad), "clearance violations")+    for b in bad[:40]:+        print(b)+    os.makedirs("../design/plots", exist_ok=True)+    render(T, V, "../design/plots/hs_all.png")+    for k in range(4):+        cx = hs.CX[k]+        render(T, V, f"../design/plots/hs_port{k}_top.png", xlim=(cx - 9, cx + 9), ylim=(22, 2), dpi=160)+    render(T, V, "../design/plots/hs_fan.png", xlim=(15, 75), ylim=(113, 86), dpi=150)+    render(T, V, "../design/plots/hs_tune.png", xlim=(10, 76), ylim=(80, 34), dpi=150)+    render(T, V, "../design/plots/hs_bus.png", xlim=(5, 76), ylim=(110, 76), dpi=150)
gen/delay.pyadded+33
@@ -0,0 +1,33 @@+#!/usr/bin/env python3+"""Delay of each differential pair: trace length per layer times sqrt(eeff_odd)/c, plus via barrels."""+import json, math, os+HERE = os.path.dirname(__file__)+Z = json.load(open(os.path.join(HERE, "..", "design", "zfinal.json")))+C_MM_PS = 0.299792458   # mm per ps+EEFF = {"L1": Z["L1"]["eeff_odd"], "L6": Z["L1"]["eeff_odd"], "L3": Z["L3"]["eeff_odd"], "L5": Z["L5"]["eeff_odd"]}+# JLC06161H-3313 z of each signal layer (mm from the top surface)+ZL = {"L1": 0.0, "L3": 0.035 + 0.0994 + 0.0152 + 0.55 + 0.0152, "L5": None, "L6": 1.6}+ZL["L5"] = ZL["L3"] + 0.1088 + 0.0152 + 0.55 + 0.0152+ER_VIA = 4.4   # barrel delay approximated through the laminate+++def pair_delay(p):+    """ps for the n trace (equal to p: intra-pair skew is zero by construction)."""+    t = 0.0+    for (lay, net, x1, y1, x2, y2, w) in p.tracks:+        if net == p.nnet:+            t += math.hypot(x2 - x1, y2 - y1) * math.sqrt(EEFF[lay]) / C_MM_PS+    # via barrels along the signal path+    layers = []+    for nd in p.nodes:+        if not layers or layers[-1] != nd[2]:+            layers.append(nd[2])+    for a, b in zip(layers, layers[1:]):+        t += abs(ZL[a] - ZL[b]) * math.sqrt(ER_VIA) / C_MM_PS+    if p.end is not None and p.end[3]:    # via-in-pad up to the L1 pad+        t += ZL[p.end[2]] * math.sqrt(ER_VIA) / C_MM_PS+    return t+++def per_mm(layer):+    return math.sqrt(EEFF[layer]) / C_MM_PS
gen/design.pyadded+208
@@ -0,0 +1,208 @@+#!/usr/bin/env python3+"""The design as data: components, footprints, MPNs and every pin's net. Writes design/netlist.json,+the single source the schematic and the board are both generated from."""+import json, os, sys+sys.path.insert(0, os.path.dirname(__file__))+from footprints import ocp_pinout++HERE = os.path.dirname(__file__)+OUT = os.path.join(HERE, "..", "design", "netlist.json")++STD = "${KICAD10_3DMODEL_DIR}"+comps = []+nets = {}+++def C(ref, value, fp, mpn, mfr, mouser, block, desc, model=None, sym=None):+    comps.append(dict(ref=ref, value=value, fp=fp, mpn=mpn, mfr=mfr, src=f"Mouser {mouser}", block=block,+                      desc=desc, model=model, sym=sym))+++def N(net, *pins):+    nets.setdefault(net, [])+    for p in pins:+        if p not in nets[net]:+            nets[net].append(p)+++# ---------------------------------------------------------------- the card edge (J1)+C("J1", "OCP_NIC3_SFF_4C+", "Riser:OCP_NIC3_SFF_4CPlus_CardEdge", "", "PCB gold fingers", "-", "host",+  "OCP NIC 3.0 SFF 4C+ card edge (PCB feature, no part)")+edge = ocp_pinout()+# pins on this passive card that stay unconnected (spec: leave as no connect)+NC = {"MAIN_PWR_EN0", "FLEXIO_0_0", "FLEXIO_0_1", "FLEXIO_0_2", "FLEXIO_0_3", "FLEXIO_0_4", "FLEXIO_0_5",+      "RBT0_ARB_IN", "RBT0_ARB_OUT", "RBT0_CLK_IN", "BIF0#", "BIF1#", "BIF2#", "SMRST0#", "PRSNTB0#", "PRSNTB1#",+      "RFU1", "RFU2", "USB_DAT0n", "USB_DAT0p", "PWRBRK0#"}+RENAME = {"+3.3V_EDGE": "+3V3_EDGE"}+for pin, net in edge.items():+    if net in NC:+        continue+    N(RENAME.get(net, net), f"J1.{pin}")++# ---------------------------------------------------------------- OCuLink ports J2..J5 (SFF-9402 Table 6-1, root side)+OCU = {"A2": "GND", "A3": "PER{}p0", "A4": "PER{}n0", "A5": "GND", "A6": "PER{}p1", "A7": "PER{}n1", "A8": "GND",+       "A11": "GND", "A12": "REFCLKp{}", "A13": "REFCLKn{}", "A14": "GND", "A15": "PER{}p2", "A16": "PER{}n2",+       "A17": "GND", "A18": "PER{}p3", "A19": "PER{}n3", "A20": "GND",+       "B2": "GND", "B3": "PET{}p0", "B4": "PET{}n0", "B5": "GND", "B6": "PET{}p1", "B7": "PET{}n1", "B8": "GND",+       "B11": "GND", "B12": "PERST{}", "B13": "CPRSNT{}", "B14": "GND", "B15": "PET{}p2", "B16": "PET{}n2",+       "B17": "GND", "B18": "PET{}p3", "B19": "PET{}n3", "B20": "GND", "SH": "GND"}+# A1/A21/B1/B21 = power pins defined NO WIRE in SFF-9402; A9 BP_TYPE, A10 CWAKE#, B9/B10 2-wire: not used by a passive root+for k in range(4):+    ref = f"J{2+k}"+    C(ref, "G14A42111C12HR", "Riser:Amphenol_G14A42111C12HR_OCuLink_4X_RA", "G14A42111C12HR",+      "Amphenol Commercial Products", "523-G14A42111C12HR", f"port{k}",+      f"OCuLink 4i port {k}: PCIe lanes {4*k}-{4*k+3}, REFCLK{k}, PERST{k}#")+    for pin, tmpl in OCU.items():+        if tmpl == "GND":+            N("GND", f"{ref}.{pin}")+            continue+        if tmpl.startswith("PER{}") or tmpl.startswith("PET{}"):+            # host-side lane names map straight through: port k lane i = host lane 4k+i+            kind = tmpl[:3]+            pol = tmpl[5]+            i = int(tmpl[6])+            host = 4 * k + i+            N(f"{kind}{pol}{host}", f"{ref}.{pin}")+        elif tmpl.startswith("REFCLK"):+            N(tmpl.format(k), f"{ref}.{pin}")+        elif tmpl == "PERST{}":+            N(f"PORT{k}_PERST#", f"{ref}.{pin}")+        elif tmpl == "CPRSNT{}":+            N(f"PORT{k}_CPRSNT#", f"{ref}.{pin}")++# ---------------------------------------------------------------- scan chain SR0 (U1, SN74LV165A)+C("U1", "SN74LV165APWR", "Package_SO:TSSOP-16_4.4x5mm_P0.65mm", "SN74LV165APWRE4", "Texas Instruments",+  "595-SN74LV165APWRE4", "scan", "OCP scan chain SCAN_DATA_IN shift register 0 (OCP 4.5.6, Table 40)",+  f"{STD}/Package_SO.3dshapes/TSSOP-16_4.4x5mm_P0.65mm.step")+N("SCAN0_LD#", "U1.1"); N("SCAN0_CLK", "U1.2")+N("+3V3_EDGE", "U1.3")          # E? no: E = bit4 WAKE_N, fixed below+nets["+3V3_EDGE"].remove("U1.3")+N("WAKE0#", "U1.3")             # E  = bit 0.4 WAKE_N mirrored from the card edge+N("+3V3_EDGE", "U1.4", "U1.5")  # F,G = bit 0.5 TEMP_WARN_N=1, 0.6 TEMP_CRIT_N=1+N("GND", "U1.6")                # H  = bit 0.7 FAN_ON_AUX=0+N("GND", "U1.8"); N("SCAN0_DATA_IN", "U1.9"); N("SR0_SER", "U1.10")+N("+3V3_EDGE", "U1.11", "U1.12")  # A,B = PRSNTB0#,PRSNTB1# = 1 (local straps, OCP Table 40 allows)+N("GND", "U1.13", "U1.14")        # C,D = PRSNTB2#,PRSNTB3# = 0 (card is 0b0011)+N("GND", "U1.15"); N("+3V3_EDGE", "U1.16")+# U1.7 /QH unused (no connect)+C("R3", "1k", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-071KL", "YAGEO", "603-RC0402FR-071KL", "scan",+  "SCAN_CLK pull-up to +3.3V_EDGE (OCP 4.5.6.1: 1 k on the NIC)", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("SCAN0_CLK", "R3.1"); N("+3V3_EDGE", "R3.2")+C("R4", "10k", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-0710KL", "YAGEO", "603-RC0402FR-0710KL", "scan",+  "SCAN_LD# pull-up 10 k (OCP 4.5.6.4)", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("SCAN0_LD#", "R4.1"); N("+3V3_EDGE", "R4.2")+C("R5", "10k", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-0710KL", "YAGEO", "603-RC0402FR-0710KL", "scan",+  "SCAN_DATA_OUT pull-down 10 k (OCP 4.5.6.2)", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("SCAN0_DATA_OUT", "R5.1", "J1.OCP_B5"); N("GND", "R5.2")+C("R6", "1k", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-071KL", "YAGEO", "603-RC0402FR-071KL", "scan",+  "SER of the last shift register pulled up 1 k (OCP 4.5.6.5)", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("SR0_SER", "R6.1"); N("+3V3_EDGE", "R6.2")+C("R7", "100k", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-07100KL", "YAGEO", "603-RC0402FR-07100KL", "scan",+  "weak pull-up on WAKE0# so scan bit 0.4 never floats (baseboard has 10 k)", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("WAKE0#", "R7.1"); N("+3V3_EDGE", "R7.2")++# ---------------------------------------------------------------- PRSNT straps: 4 x4 card, PRSNTB[3:0]# = 0b0011+C("R1", "100", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-07100RL", "YAGEO", "603-RC0402FR-07100RL", "host",+  "PRSNTA0# to PRSNTB2# strap, series 0-200 ohm (OCP 4.5.2.2)", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("PRSNTA0#", "R1.1"); N("PRSNTB2#", "R1.2")+C("R2", "100", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-07100RL", "YAGEO", "603-RC0402FR-07100RL", "host",+  "PRSNTA0# to PRSNTB3# strap, series 0-200 ohm (OCP 4.5.2.2)", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("PRSNTA0#", "R2.1"); N("PRSNTB3#", "R2.2")++# ---------------------------------------------------------------- FRU EEPROM (U2)+C("U2", "BR24H64FJ-5ACE2", "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm", "BR24H64FJ-5ACE2", "ROHM Semiconductor",+  "755-BR24H64FJ-5ACE2", "fru", "FRU EEPROM 64 Kbit, double-byte addressing, address 0xA0 + SLOT_ID (OCP 5.10)",+  f"{STD}/Package_SO.3dshapes/SOIC-8_3.9x4.9mm_P1.27mm.step")+N("GND", "U2.1", "U2.4"); N("SLOT_ID0", "U2.2"); N("SLOT_ID1", "U2.3")+N("SMDAT0", "U2.5"); N("SMCLK0", "U2.6"); N("FRU_WP", "U2.7"); N("+3V3_EDGE", "U2.8")+C("R8", "10k", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-0710KL", "YAGEO", "603-RC0402FR-0710KL", "fru",+  "FRU write protect pull-up: protected unless JP1 is bridged (OCP 5.10.2)", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("FRU_WP", "R8.1"); N("+3V3_EDGE", "R8.2")+C("JP1", "FRU_WRITE", "Jumper:SolderJumper-2_P1.3mm_Open_RoundedPad1.0x1.5mm", "", "PCB solder jumper", "-", "fru",+  "Bridge to allow writing the FRU EEPROM")+N("FRU_WP", "JP1.1"); N("GND", "JP1.2")++# ---------------------------------------------------------------- NIC_PWR_GOOD (U3)+C("U3", "TPS3808G33", "Package_TO_SOT_SMD:SOT-23-6", "TPS3808G33DBVTG4", "Texas Instruments", "595-TPS3808G33DBVTG4",+  "power", "NIC_PWR_GOOD: +3.3V_EDGE within tolerance AND AUX_PWR_EN high; 20 ms delay with CT open (OCP 4.5.7.6, TAPL 50 ms)",+  f"{STD}/Package_TO_SOT_SMD.3dshapes/SOT-23-6.step")+N("NIC_PWR_GOOD0", "U3.1"); N("GND", "U3.2"); N("AUX_PWR_EN0", "U3.3"); N("+3V3_EDGE", "U3.5", "U3.6")+# U3.4 CT open = 20 ms+C("R9", "10k", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-0710KL", "YAGEO", "603-RC0402FR-0710KL", "power",+  "NIC_PWR_GOOD open-drain pull-up", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("NIC_PWR_GOOD0", "R9.1"); N("+3V3_EDGE", "R9.2")++# ---------------------------------------------------------------- PERST# buffers (U4) and per-port terminations+C("U4", "SN74LVC125APWR", "Package_SO:TSSOP-14_4.4x5mm_P0.65mm", "SN74LVC125APWR", "Texas Instruments",+  "595-SN74LVC125APWR", "perst", "PERST0..3# buffers to the OCuLink ports; Ioff keeps the outputs off when the slot is unpowered",+  f"{STD}/Package_SO.3dshapes/TSSOP-14_4.4x5mm_P0.65mm.step")+N("GND", "U4.1", "U4.4", "U4.10", "U4.13", "U4.7"); N("+3V3_EDGE", "U4.14")+for k, (a, y) in enumerate([("2", "3"), ("5", "6"), ("9", "8"), ("12", "11")]):+    N(f"PERST{k}#", f"U4.{a}"); N(f"PERST{k}_BUF", f"U4.{y}")+    rs = f"R{11+k}"; rp = f"R{15+k}"+    C(rs, "33", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-0733RL", "YAGEO", "603-RC0402FR-0733RL", "perst",+      f"PERST{k}# series termination at the buffer", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+    N(f"PERST{k}_BUF", f"{rs}.1"); N(f"PORT{k}_PERST#", f"{rs}.2")+    C(rp, "100k", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-07100KL", "YAGEO", "603-RC0402FR-07100KL", "perst",+      f"port {k} PERST# held asserted while the slot is unpowered", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+    N(f"PORT{k}_PERST#", f"{rp}.1"); N("GND", f"{rp}.2")++# ---------------------------------------------------------------- ESD at each port (sideband pins leave the chassis)+for k in range(4):+    ref = f"U{5+k}"+    C(ref, "TPD4E05U06", "Package_SON:USON-10_2.5x1.0mm_P0.5mm", "TPD4E05U06QDQARQ1", "Texas Instruments",+      "595-PD4E05U06QDQARQ1", f"port{k}", f"ESD clamps on port {k} PERST# and CPRSNT#",+      f"{STD}/Package_SON.3dshapes/USON-10_2.5x1.0mm_P0.5mm.step")+    N(f"PORT{k}_PERST#", f"{ref}.1"); N(f"PORT{k}_CPRSNT#", f"{ref}.2"); N("GND", f"{ref}.3", f"{ref}.8")+    # channels D2+ / D2- (pins 4, 5) unused: tie to GND so they are not floating antennas+    N("GND", f"{ref}.4", f"{ref}.5")++# ---------------------------------------------------------------- power indication, decoupling, test points+C("D1", "GREEN", "LED_SMD:LED_0603_1608Metric", "IN-S63BTG", "Inolux", "743-IN-S63BTG", "power",+  "+3.3V_EDGE present (slot aux power)", f"{STD}/LED_SMD.3dshapes/LED_0603_1608Metric.step")+C("R23", "1k", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-071KL", "YAGEO", "603-RC0402FR-071KL", "power",+  "D1 current, (3.3-2.0)/1k = 1.3 mA", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("+3V3_EDGE", "R23.1"); N("LED3V3_A", "R23.2", "D1.2"); N("GND", "D1.1")+C("D2", "GREEN", "LED_SMD:LED_0603_1608Metric", "IN-S63BTG", "Inolux", "743-IN-S63BTG", "power",+  "+12V_EDGE present (slot main power)", f"{STD}/LED_SMD.3dshapes/LED_0603_1608Metric.step")+C("R24", "4.7k", "Riser:R_0402_1005Metric_NoSilk", "RC0402FR-074K7L", "YAGEO", "603-RC0402FR-074K7L", "power",+  "D2 current, (12-2.0)/4.7k = 2.1 mA, 21 mW", f"{STD}/Resistor_SMD.3dshapes/R_0402_1005Metric.step")+N("+12V_EDGE", "R24.1"); N("LED12V_A", "R24.2", "D2.2"); N("GND", "D2.1")+for i, (ref, near) in enumerate([("C1", "U1"), ("C2", "U2"), ("C3", "U3"), ("C4", "U4")]):+    C(ref, "100n", "Riser:C_0402_1005Metric_NoSilk", "CL05B104KO5NNNC", "Samsung Electro-Mechanics", "187-CL05B104KO5NNNC", "power",+      f"{near} decoupling", f"{STD}/Capacitor_SMD.3dshapes/C_0402_1005Metric.step")+    N("+3V3_EDGE", f"{ref}.1"); N("GND", f"{ref}.2")+C("C5", "10u", "Riser:C_0603_1608Metric_NoSilk", "CL10A106KQ8NNNC", "Samsung Electro-Mechanics", "187-CL10A106KQ8NNNC",+  "power", "+3.3V_EDGE bulk", f"{STD}/Capacitor_SMD.3dshapes/C_0603_1608Metric.step")+N("+3V3_EDGE", "C5.1"); N("GND", "C5.2")+for ref, net, val in [("TP1", "+3V3_EDGE", "+3V3"), ("TP2", "GND", "GND"), ("TP3", "SMCLK0", "SMCLK"),+                      ("TP4", "SMDAT0", "SMDAT"), ("TP5", "+12V_EDGE", "+12V")]:+    C(ref, val, "TestPoint:TestPoint_Pad_D1.0mm", "", "PCB test pad", "-", "test", f"test/programming pad {val}")+    N(net, f"{ref}.1")+# probe spots for the bench test plan (design/tests.json): 0.5 mm pads placed on each net's routed top-layer trace+for ref, net, val in [("TP6", "NIC_PWR_GOOD0", "PWRGD"), ("TP7", "PORT0_PERST#", "PERST0"), ("TP8", "PORT1_PERST#", "PERST1"),+                      ("TP9", "PORT2_PERST#", "PERST2"), ("TP10", "PORT3_PERST#", "PERST3"), ("TP11", "SCAN0_CLK", "SCLK"),+                      ("TP12", "SCAN0_DATA_OUT", "SDOUT")]:+    C(ref, val, "Riser:TestPoint_Pad_D0.5mm", "", "PCB test pad", "-", "test", f"probe spot {net}")+    N(net, f"{ref}.1")+for ref in ("H1", "H2"):+    C(ref, "Faceplate_GND", "Riser:OCP_SFF_Faceplate_Ground_3.2mm", "", "PCB feature", "-", "mech",+      "OCP SFF faceplate mounting and grounding hole")+    N("GND", f"{ref}.1")++# every IC carries its laser-etched MPN (adom-aiflow-etch, kicad/3d/etch; plain KiCad models in kicad/3d/src)+for c in comps:+    if c["ref"].startswith("U"):+        c["model"] = "${KIPRJMOD}/3d/etch/" + c["fp"].split(":")[1] + "_" + c["mpn"] + ".step"++if __name__ == "__main__":+    os.makedirs(os.path.dirname(OUT), exist_ok=True)+    # sanity: every pin once+    seen = {}+    for net, pins in nets.items():+        for p in pins:+            if p in seen:+                sys.exit(f"pin {p} on {seen[p]} and {net}")+            seen[p] = net+    json.dump({"design": "ocp-oculink-riser", "components": comps, "nets": nets}, open(OUT, "w"), indent=1)+    print(f"{len(comps)} components, {len(nets)} nets, {len(seen)} connected pins -> {OUT}")
gen/export_fab.pyadded+105
@@ -0,0 +1,105 @@+#!/usr/bin/env python3+"""Write the manufacturing set from the finished board with kicad-cli, then re-read every file and check it against+the board: 6 copper Gerbers + mask/paste/silk/edge, Excellon drill (PTH and NPTH) whose hit count equals the board's+holes, a CPL whose references and rotations equal the board's placed parts, a BOM whose references cover them.+Writes fab/ (gerbers.zip, the loose files, bom.csv, cpl.csv) and fab/export-check.json; exits 1 on any mismatch."""+import csv, glob, json, os, re, subprocess, sys, zipfile++ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")+K = "/mnt/c/Users/john/AppData/Local/Programs/KiCad/10.0/bin"+BOARD = os.path.join(ROOT, "kicad", "ocp-oculink-riser.kicad_pcb")+FAB = os.path.join(ROOT, "fab")+G = os.path.join(FAB, "gerbers")+os.makedirs(G, exist_ok=True)+for f in glob.glob(os.path.join(G, "*")):+    os.remove(f)+++def wp(p):+    return subprocess.run(["wslpath", "-w", p], capture_output=True, text=True).stdout.strip()+++def cli(*args):+    r = subprocess.run([f"{K}/kicad-cli.exe", *args], capture_output=True, text=True)+    if r.returncode:+        raise SystemExit(f"kicad-cli {' '.join(args[:3])} failed: {r.stdout}{r.stderr}")+    return r.stdout+++LAYERS = "F.Cu,In1.Cu,In2.Cu,In3.Cu,In4.Cu,B.Cu,F.Paste,B.Paste,F.Silkscreen,B.Silkscreen,F.Mask,B.Mask,Edge.Cuts"+cli("pcb", "export", "gerbers", "--output", wp(G) + "\\", "--layers", LAYERS, "--subtract-soldermask",+    "--use-drill-file-origin", wp(BOARD))+cli("pcb", "export", "drill", "--output", wp(G) + "\\", "--format", "excellon", "--excellon-separate-th",+    "--excellon-units", "mm", "--drill-origin", "plot", "--generate-map", "--map-format", "gerberx2", wp(BOARD))+pos = os.path.join(FAB, "pos-kicad.csv")+cli("pcb", "export", "pos", "--output", wp(pos), "--format", "csv", "--units", "mm", "--side", "both",+    "--exclude-dnp", "--use-drill-file-origin", wp(BOARD))++check = {"board": "kicad/ocp-oculink-riser.kicad_pcb", "problems": []}+files = sorted(os.listdir(G))+check["files"] = files+# copper layers: six Gerbers carrying copper+cu = [f for f in files if re.search(r"(F_Cu|In[1-4]_Cu|B_Cu)\.g", f)]+check["copperGerbers"] = len(cu)+if len(cu) != 6:+    check["problems"].append(f"expected 6 copper Gerbers, found {len(cu)}: {cu}")+for f in cu:+    txt = open(os.path.join(G, f), errors="ignore").read()+    if "D03*" not in txt and "D01*" not in txt:+        check["problems"].append(f"{f} has no drawn copper")+# drill hits against the board's holes+txt_b = open(BOARD).read()+vias = len(re.findall(r"^\t\(via\b", txt_b, re.M))+pth_pads = sum(1 for m in re.finditer(r"\(pad \"[^\"]*\" thru_hole", txt_b))+npth_pads = sum(1 for m in re.finditer(r"\(pad \"[^\"]*\" np_thru_hole", txt_b))+hits = {"PTH": 0, "NPTH": 0}+for f in files:+    if f.endswith(".drl"):+        kind = "NPTH" if "NPTH" in f else "PTH"+        hits[kind] += sum(1 for line in open(os.path.join(G, f)) if re.match(r"^X-?[\d.]+Y-?[\d.]+", line.strip()) or re.match(r"^G85", line.strip()))+check["drill"] = {"pthHits": hits["PTH"], "npthHits": hits["NPTH"], "boardVias": vias, "boardPthPads": pth_pads, "boardNpthPads": npth_pads}+if hits["PTH"] != vias + pth_pads:+    check["problems"].append(f"PTH drill hits {hits['PTH']} != vias {vias} + plated pads {pth_pads}")+if hits["NPTH"] != npth_pads:+    check["problems"].append(f"NPTH drill hits {hits['NPTH']} != NPTH pads {npth_pads}")+# CPL: JLC column names, every placed part, rotations as on the board+rows = list(csv.DictReader(open(pos)))+cpl = os.path.join(FAB, "cpl.csv")+with open(cpl, "w", newline="") as fh:+    w = csv.writer(fh)+    w.writerow(["Designator", "Val", "Package", "Mid X", "Mid Y", "Rotation", "Layer"])+    for r in rows:+        w.writerow([r["Ref"], r["Val"], r["Package"], r["PosX"], r["PosY"], r["Rot"], "Top" if r["Side"] == "top" else "Bottom"])+cpl_refs = {r["Ref"] for r in rows}+board_refs = set()+for m in re.finditer(r'\(footprint "([^"]+)".*?\(property "Reference" "([^"]+)"', txt_b, re.S):+    board_refs.add(m.group(2))+excluded = {"J1", "H1", "H2", "JP1"} | {r for r in board_refs if r.startswith("TP")}+placed = board_refs - excluded+check["cpl"] = {"rows": len(rows), "missing": sorted(placed - cpl_refs), "extra": sorted(cpl_refs - placed)}+if placed - cpl_refs or cpl_refs - placed:+    check["problems"].append(f"CPL refs differ from placed parts: missing {sorted(placed - cpl_refs)} extra {sorted(cpl_refs - placed)}")+# BOM: the design BOM, purchased parts only, grouped by MPN+bom_rows = [r for r in csv.DictReader(open(os.path.join(ROOT, "design", "bom.csv"))) if r["mpn"] not in ("no part", "")]+bom_refs = {r["ref"] for r in bom_rows}+check["bom"] = {"lines": len(bom_rows), "missing": sorted(placed - bom_refs), "extra": sorted(bom_refs - placed)}+if placed - bom_refs:+    check["problems"].append(f"BOM lacks placed parts: {sorted(placed - bom_refs)}")+groups = {}+for r in bom_rows:+    g = groups.setdefault(r["mpn"], dict(r, refs=[]))+    g["refs"].append(r["ref"])+with open(os.path.join(FAB, "bom.csv"), "w", newline="") as fh:+    w = csv.writer(fh)+    w.writerow(["Comment", "Designator", "Footprint", "Qty", "Manufacturer", "MPN", "Mouser", "Stock (checked 2026-10-12)"])+    for mpn, g in sorted(groups.items(), key=lambda kv: kv[1]["refs"][0]):+        w.writerow([g["value"], ",".join(sorted(g["refs"], key=lambda s: (s[0], int(re.sub(r'\D', '', s) or 0)))), g["package"], len(g["refs"]),+                    g["manufacturer"], mpn, g["vendor_pn"], g[[k for k in g if k.startswith("stock_checked")][0]]])+# zip the Gerbers and drills for upload+with zipfile.ZipFile(os.path.join(FAB, "gerbers.zip"), "w", zipfile.ZIP_DEFLATED) as z:+    for f in files:+        z.write(os.path.join(G, f), f)+check["ok"] = not check["problems"]+json.dump(check, open(os.path.join(FAB, "export-check.json"), "w"), indent=1)+print(json.dumps({k: check[k] for k in ("copperGerbers", "drill", "cpl", "bom", "problems", "ok")}, indent=1))+sys.exit(0 if check["ok"] else 1)
gen/finish_routes.pyadded+329
@@ -0,0 +1,329 @@+#!/usr/bin/env python3+"""After the maze: GND fan-out vias for SMD ground pads, GND stitching and return vias, pours and keepouts,+board text. Writes design/routes.json (everything build_board.py final needs)."""+import json, math, os, sys, time+import numpy as np+from scipy import ndimage+from shapely.geometry import Polygon, box, Point+sys.path.insert(0, os.path.dirname(__file__))+import route_all as R+import maze, hs+from maze import G, LI, NX, NY++ROOT = R.ROOT+ls = json.load(open(os.path.join(ROOT, "design", "routes_ls.json")))+B = R.B+T, V = R.T, R.V+_ts = set(map(tuple, T)); _vs = set(map(tuple, V))+for t in map(tuple, ls["tracks"]):+    if t not in _ts:+        T.append(t); B.track(*t); _ts.add(t)+for v in map(tuple, ls["vias"]):+    if v not in _vs:+        V.append(v); B.via(v[0], v[1], v[2], v[3]); _vs.add(v)+gnd = B.nid("GND")+++def holes_mask(gap=0.62):+    """cells too close to any existing drilled hole (via or plated pad) for hole-to-hole spacing"""+    m = np.zeros((NY, NX), bool)+    pts = [(v[0], v[1], v[4] if len(v) > 4 else 0.2) for v in V]+    pts += [(p["x"], p["y"], max(p["drill"])) for p in R.pads if p["drill"][0] > 0]+    for (x, y, h) in pts:+        r = gap + h / 2+        j0, j1 = max(0, int((y - r) / G)), min(NY - 1, int((y + r) / G) + 1)+        i0, i1 = max(0, int((x - r) / G)), min(NX - 1, int((x + r) / G) + 1)+        yy_, xx_ = np.mgrid[j0:j1 + 1, i0:i1 + 1]+        m[j0:j1 + 1, i0:i1 + 1] |= (xx_ * G - x) ** 2 + (yy_ * G - y) ** 2 < r * r+    return m+++def via_ok_mask(clr=0.25, clr_hs=0.35, d=maze.VIA_D):+    ok = ~holes_mask()+    for li in range(4):+        lab = B.lab[li]+        other = (lab >= 0) & (lab != gnd)+        ok &= ndimage.distance_transform_edt(~other) * G >= clr + d / 2+        oh = other & B.hs[li]+        ok &= ndimage.distance_transform_edt(~oh) * G >= clr_hs + d / 2+        ok &= ~B.block_via[li]+    # keep top/bottom copper out of the 2.75 mm long-edge strips (OCP: component and trace free)+    ok &= (B.X > 3.0) & (B.X < 73.0)+    return ok+++# ---------------------------------------------------------------- probe-pad stubs (design/tp_stubs.json)+_st = os.path.join(ROOT, "design", "tp_stubs.json")+if os.path.exists(_st):+    for t in json.load(open(_st))["tracks"]:+        t = tuple(t); T.append(t); B.track(*t)++# ---------------------------------------------------------------- GND fan-out for SMD ground pads+fan = 0+FAN_T, FAN_V = [], []+FIXED_NETS = {t[1] for t in ls.get("fixed_tracks", [])} | {v[2] for v in ls.get("fixed_vias", [])}+++def fan_pad(p):+    vok = via_ok_mask(0.25, 0.32)+    cx, cy = p["x"], p["y"]+    j0, i0 = int(round(cy / G)), int(round(cx / G))+    r = int(2.5 / G)+    jj, ii = np.nonzero(vok[max(0, j0 - r):j0 + r + 1, max(0, i0 - r):i0 + r + 1])+    goal = {(LI["L1"], int(j + max(0, j0 - r)), int(i + max(0, i0 - r))) for j, i in zip(jj, ii)}+    x0, y0, x1, y1 = p["geom"].bounds+    from shapely import contains_xy+    sj0, sj1, si0, si1 = int(y0 / G), int(y1 / G) + 1, int(x0 / G), int(x1 / G) + 1+    sub = contains_xy(p["geom"].buffer(-0.02), B.X[sj0:sj1 + 1, si0:si1 + 1], B.Y[sj0:sj1 + 1, si0:si1 + 1])+    sjs, sis = np.nonzero(sub)+    start = {(LI["L1"], int(j + sj0), int(i + si0)) for j, i in zip(sjs, sis)}+    if not goal or not start:+        return False+    paths, msg = maze.route_net(B, "GND", [start, goal], w=0.25, clr=0.2, clr_hs=0.32, allow=("L1",), max_expand=400000)+    if paths is None:+        # no via site nearby: a short top-layer trace to GND copper already there (another GND pad, a fan-out)+        own = p["geom"].buffer(0.25)+        gj, gi = np.nonzero(B.lab[LI["L1"], max(0, j0 - r):j0 + r + 1, max(0, i0 - r):i0 + r + 1] == gnd)+        goal2 = {(LI["L1"], int(j + max(0, j0 - r)), int(i + max(0, i0 - r))) for j, i in zip(gj, gi)}+        goal2 = {c for c in goal2 if not own.contains(Point(c[2] * G, c[1] * G))}+        if goal2:+            paths, msg = maze.route_net(B, "GND", [start, goal2], w=0.2, clr=0.15, clr_hs=0.3, allow=("L1",), max_expand=400000)+        if paths is not None:+            tr, _ = maze.path_to_copper(paths[0], "GND", 0.2)+            for t in tr:+                T.append(t); B.track(*t); FAN_T.append(t)+            print("GND trace to nearby GND copper for", p["ref"], p["num"])+            return True+    if paths is None:+        # no room beside the pad: a filled, capped via in the pad (JLC 6-layer POFV, as at the OCuLink pads)+        d_ip, h_ip = 0.3, 0.15+        vin = ~holes_mask(0.35)+        for li in range(4):+            other = (B.lab[li] >= 0) & (B.lab[li] != gnd)+            vin &= ndimage.distance_transform_edt(~other) * G >= 0.15 + d_ip / 2+            oh = other & B.hs[li]+            vin &= ndimage.distance_transform_edt(~oh) * G >= 0.28 + d_ip / 2+        cand = [(math.hypot(c[2] * G - cx, c[1] * G - cy), c) for c in start if vin[c[1], c[2]]+                and p["geom"].buffer(-0.03).contains(Point(c[2] * G, c[1] * G).buffer(d_ip / 2))]+        if not cand:+            cand = [(math.hypot(c[2] * G - cx, c[1] * G - cy), c) for c in start if vin[c[1], c[2]]]+        if not cand:+            return False+        c = min(cand)[1]+        v = (round(c[2] * G, 4), round(c[1] * G, 4), "GND", d_ip, h_ip)+        V.append(v); B.via(v[0], v[1], "GND", d_ip); FAN_V.append(v)+        print("GND via-in-pad for", p["ref"], p["num"], v[:2])+        return True+    tr, _ = maze.path_to_copper(paths[0], "GND", 0.25)+    end = paths[0][-1]+    for t in tr:+        T.append(t); B.track(*t); FAN_T.append(t)+    v = (round(end[2] * G, 4), round(end[1] * G, 4), "GND", maze.VIA_D, maze.VIA_H)+    V.append(v); B.via(v[0], v[1], "GND", v[3]); FAN_V.append(v)+    return True+++def unpaint(net):+    nid = B.nid(net)+    B.lab[B.lab == nid] = -1+    for q in R.pads:+        if q["net"] == net:+            B.paint_geom(q["lis"], q["geom"], nid)+++def rip_and_fan(p):+    """Last resort for a boxed-in GND pad: rip the low-speed nets crowding it on L1, fan the pad out, then+    route those nets again around the new copper (restoring them as they were if that fails)."""+    near = p["geom"].buffer(0.7)+    x0, y0, x1, y1 = near.bounds+    j0, j1, i0, i1 = int(y0 / G), int(y1 / G) + 1, int(x0 / G), int(x1 / G) + 1+    ids = set(np.unique(B.lab[:, j0:j1 + 1, i0:i1 + 1])) - {-1, gnd}+    rips = sorted(B.netname[i] for i in ids if B.netname[i] not in FIXED_NETS and not B.netname[i].startswith(("+", "PE", "REFCLK")))+    if not rips:+        return False+    saved = {n: ([t for t in T if t[1] == n], [v for v in V if v[2] == n]) for n in rips}+    for n in rips:+        T[:] = [t for t in T if t[1] != n]; V[:] = [v for v in V if v[2] != n]+        unpaint(n)+    nT, nV = len(FAN_T), len(FAN_V)+    ok = fan_pad(p)+    if ok:+        newc = {}+        for n in rips:+            w, allow = {r_[0]: (r_[1], r_[2]) for r_ in R.ROUTE}.get(n, (0.15, ("L1", "L3", "L5")))+            paths, msg = maze.route_net(B, n, R.terminals(n), w=w, clr=R.CLR, clr_hs=R.CLR_HS, allow=allow, l6_region=R.L6_REGION)+            if paths is None:+                ok = False; print("  reroute failed:", n, msg); break+            tr, vi = [], []+            for pth in paths:+                a_, b_ = maze.path_to_copper(pth, n, w); tr += a_; vi += b_+            for t in tr: B.track(*t)+            for v in vi: B.via(v[0], v[1], n, v[3])+            newc[n] = (tr, vi)+        if ok:+            for n, (tr, vi) in newc.items():+                T.extend(tr); V.extend(vi)+            print("  ripped and re-routed", rips, "for", p["ref"], p["num"])+            return True+        # undo: drop the fan-out and the partial reroutes, restore the old copper+        drop_t, drop_v = set(map(tuple, FAN_T[nT:])), set(map(tuple, FAN_V[nV:]))+        del FAN_T[nT:]; del FAN_V[nV:]+        T[:] = [t for t in T if tuple(t) not in drop_t]; V[:] = [v for v in V if tuple(v) not in drop_v]+        unpaint("GND")+        for t in T:+            if t[1] == "GND": B.track(*t)+        for v in V:+            if v[2] == "GND": B.via(v[0], v[1], "GND", v[3])+    for n in rips:+        unpaint(n)+        T[:] = [t for t in T if t[1] != n]; V[:] = [v for v in V if v[2] != n]+        tr, vi = saved[n]+        T.extend(tr); V.extend(vi)+        for t in tr: B.track(*t)+        for v in vi: B.via(v[0], v[1], n, v[3])+    return False+++for p in R.pads:+    if p["net"] != "GND" or p["ref"] in ("J1", "J2", "J3", "J4", "J5", "H1", "H2") or p["drill"][0] > 0:+        continue+    if fan_pad(p) or rip_and_fan(p):+        fan += 1+    else:+        print("no GND fan-out for", p["ref"], p["num"])+print("GND fan-out vias:", fan)++# ---------------------------------------------------------------- return vias beside every high-speed via pair+vok = via_ok_mask(0.25, 0.3)+ret = 0+hsv = [v for v in V if v[2] != "GND" and (v[2].startswith(("PET", "PER", "REFCLK")))]+taken = []+for (x, y, net, d, h) in hsv:+    # look for a GND via spot within 1.1 mm, prefer the closest, at most one per signal via+    j0, i0 = int(round(y / G)), int(round(x / G))+    cands = []+    for dj in range(-11, 12):+        for di in range(-11, 12):+            j, i = j0 + dj, i0 + di+            if 0 <= j < NY and 0 <= i < NX and vok[j, i]:+                rr = math.hypot(dj, di) * G+                if 0.55 <= rr <= 1.1:+                    cands.append((rr, j, i))+    cands.sort()+    for rr, j, i in cands:+        if all(math.hypot(i * G - a, j * G - b) > 0.65 for (a, b) in taken):+            v = (i * G, j * G, "GND", maze.VIA_D, maze.VIA_H)+            V.append(v); B.via(i * G, j * G, "GND", maze.VIA_D)+            taken.append((i * G, j * G)); ret += 1+            vok = None+            break+    if vok is None:+        vok = via_ok_mask(0.25, 0.3)+print("return vias:", ret)++# ---------------------------------------------------------------- stitching grid+vok = via_ok_mask(0.25, 0.35)+st = 0+pitch = 2.0+for yy in np.arange(1.2, 112.0, pitch):+    for xx in np.arange(3.2, 73.0, pitch):+        j, i = int(round(yy / G)), int(round(xx / G))+        if vok[j, i]:+            V.append((round(xx, 3), round(yy, 3), "GND", maze.VIA_D, maze.VIA_H)); st += 1+            # keep the mask honest for the next candidates+            r = int(0.8 / G)+            vok[max(0, j - r):j + r + 1, max(0, i - r):i + r + 1] = False+print("stitching vias:", st)++# ---------------------------------------------------------------- pours+outline = Polygon(R.lay["outline"])+outer = outline.buffer(-0.3).intersection(box(2.75, 0.3, 76 - 2.75, 111.95))+inner = outline.buffer(-1.0).intersection(box(1.0, 0.3, 75.0, 113.6))+++def poly_pts(g):+    if g.geom_type == "MultiPolygon":+        g = max(g.geoms, key=lambda q: q.area)+    return [(round(x, 4), round(y, 4)) for (x, y) in list(g.exterior.coords)[:-1]]++++def prune_dangling():+    """Drop signal stubs that end nowhere (a fixed bus start the maze tapped further along): a via touching+    fewer than two tracks and no pad, and a track whose free end touches no pad, via or other track."""+    from shapely.geometry import LineString+    padg = {}+    for p in R.pads:+        padg.setdefault(p["net"], []).append(p["geom"])+    removed = 0+    while True:+        changed = False+        for net in sorted({t[1] for t in T} - {"GND"}):+            tr = [t for t in T if t[1] == net]+            vi = [v for v in V if v[2] == net]+            pg = padg.get(net, [])++            def on_track(x, y, layer, skip):+                pt = Point(x, y)+                return any(t is not skip and t[0] == layer and+                           LineString([(t[2], t[3]), (t[4], t[5])]).distance(pt) <= (t[6] + skip[6]) / 2 - 0.005 for t in tr)++            def at_via(x, y, w):+                return any(math.hypot(v[0] - x, v[1] - y) <= (v[3] + w) / 2 - 0.005 for v in vi)++            def in_pad(x, y, r=0.0):+                return any(g.distance(Point(x, y)) <= max(r - 0.005, 1e-3) for g in pg)+            for v in vi:+                n = sum(1 for t in tr if LineString([(t[2], t[3]), (t[4], t[5])]).distance(Point(v[0], v[1])) <= (v[3] + t[6]) / 2 - 0.005)+                if n < 2 and not in_pad(v[0], v[1], v[3] / 2):+                    V.remove(v); removed += 1; changed = True+            vi = [v for v in V if v[2] == net]+            for t in tr:+                for end in (0, 1):+                    x, y = (t[4], t[5]) if end else (t[2], t[3])+                    if in_pad(x, y, t[6] / 2) or at_via(x, y, t[6]) or on_track(x, y, t[0], t):+                        continue+                    # free end: cut the segment back to the last tap along it (another track's end, a via or+                    # a pad touching it), or drop it when nothing touches it but its other end+                    sx, sy = (t[2], t[3]) if end else (t[4], t[5])+                    L = math.hypot(x - sx, y - sy)+                    seg = LineString([(sx, sy), (x, y)])+                    us = []+                    for o in tr:+                        if o is t or o[0] != t[0]:+                            continue+                        for (ox, oy) in ((o[2], o[3]), (o[4], o[5])):+                            if seg.distance(Point(ox, oy)) <= (o[6] + t[6]) / 2 - 0.005:+                                us.append(seg.project(Point(ox, oy)))+                    for v in vi:+                        if seg.distance(Point(v[0], v[1])) <= (v[3] + t[6]) / 2 - 0.005:+                            us.append(seg.project(Point(v[0], v[1])))+                    for g in pg:+                        if g.distance(seg) <= t[6] / 2 - 0.005:+                            us.append(seg.project(g.centroid))+                    us = [u for u in us if 1e-3 < u < L - 1e-3]+                    T.remove(t); removed += 1; changed = True+                    if us:+                        u = max(us)+                        nx, ny = sx + (x - sx) * u / L, sy + (y - sy) * u / L+                        nt = (t[0], t[1], round(sx, 4), round(sy, 4), round(nx, 4), round(ny, 4), t[6])+                        T.append(nt)+                    break+        if not changed:+            return removed+++print("dangling stubs pruned:", prune_dangling())++zones = []+for layer, g, clr in (("L1", outer, 0.35), ("L6", outer, 0.35), ("L3", inner, 0.3), ("L5", inner, 0.3),+                      ("L2", inner, 0.25), ("L4", inner, 0.25)):+    zones.append({"layer": layer, "net": "GND", "poly": poly_pts(g), "clearance": clr, "priority": 0,+                  "thermal": False})++texts = [+    {"text": "OCP NIC 3.0 SFF  ->  4x OCuLink 4i", "x": 38.0, "y": 104.6, "layer": "F.SilkS", "size": 1.0},+]+json.dump({"tracks": T, "vias": V, "zones": zones, "texts": [], "fanout_tracks": FAN_T, "fanout_vias": FAN_V, "stats": {"fanout": fan, "return": ret, "stitch": st}},+          open(os.path.join(ROOT, "design", "routes.json"), "w"))+print("routes.json:", len(T), "tracks,", len(V), "vias,", len(zones), "zones")
gen/footprints.pyadded+216
@@ -0,0 +1,216 @@+#!/usr/bin/env python3+"""Project footprints for the OCP NIC 3.0 SFF to 4x OCuLink riser, written as KiCad s-expressions.++Sources (every number below is quoted from these):+  OCP NIC 3.0 Design Specification v1.6.0 (2025-03-31): Table 20 (SFF pinout), Table 8 (mating sequence),+      Fig. 8/9/10 (SFF outline, faceplate grounding pads).+  SFF-TA-1002 Rev 1.6.2a: Fig. 5-39 (AIC 4C+ mating card), Fig. 5-41 (Detail D pad dimensions).+  Amphenol drawing G14A421X1BXXXHR rev D (OCuLink 4X R/A receptacle, shell DIP; option C = 2.90 mm legs),+      'Recommended PCB layout - top view'.+"""+import os, uuid++OUT = os.path.join(os.path.dirname(__file__), "..", "kicad", "lib", "Riser.pretty")+NS = uuid.UUID("6f1c2b0e-0c3a-4b6e-9a51-6f2c1d0e4a10")+++def u(*k):+    return str(uuid.uuid5(NS, "/".join(map(str, k))))+++def f(v):+    s = f"{v:.4f}".rstrip("0").rstrip(".")+    return "0" if s in ("-0", "") else s+++def header(name, descr, tags, attr="smd"):+    return [f'(footprint "{name}"', '  (version 20241229)', '  (generator "riser-gen")', '  (generator_version "1.0")',+            '  (layer "F.Cu")', f'  (descr "{descr}")', f'  (tags "{tags}")',+            f'  (attr {attr})']+++def text(kind, val, x, y, layer, hide=False, size=0.8):+    h = " (hide yes)" if hide else ""+    return (f'  (property "{kind}" "{val}" (at {f(x)} {f(y)} 0) (layer "{layer}"){h} (uuid "{u(kind, val, x, y)}")\n'+            f'    (effects (font (size {size} {size}) (thickness {f(size*0.15)}))))')+++def line(x1, y1, x2, y2, layer, w=0.12):+    return f'  (fp_line (start {f(x1)} {f(y1)}) (end {f(x2)} {f(y2)}) (stroke (width {f(w)}) (type solid)) (layer "{layer}") (uuid "{u("l", x1, y1, x2, y2, layer)}"))'+++def rect(x1, y1, x2, y2, layer, w=0.05, fill=False):+    fl = "yes" if fill else "no"+    return f'  (fp_rect (start {f(x1)} {f(y1)}) (end {f(x2)} {f(y2)}) (stroke (width {f(w)}) (type solid)) (fill {fl}) (layer "{layer}") (uuid "{u("r", x1, y1, x2, y2, layer)}"))'+++def smd(num, x, y, w, h, layer="F", shape="rect", extra=""):+    lay = f'"{layer}.Cu" "{layer}.Mask"' + (f' "{layer}.Paste"' if layer and "nopaste" not in extra else "")+    return f'  (pad "{num}" smd {shape} (at {f(x)} {f(y)}) (size {f(w)} {f(h)}) (layers {lay}){extra.replace("nopaste","")} (uuid "{u("p", num, x, y, layer)}"))'+++def write(name, lines):+    os.makedirs(OUT, exist_ok=True)+    with open(os.path.join(OUT, name + ".kicad_mod"), "w") as fh:+        fh.write("\n".join(lines) + "\n)\n")+++# ---------------------------------------------------------------- OCP 4C+ gold fingers+def ocp_edge():+    """Origin: datum F (centre of the B28/B29 key) on the card edge (datum G). -y points into the card.+    Pads: 0.38 wide (SFF-TA-1002 Fig 5-41), top end 3.00 from G; bottom end 0.88 from G for 1st-mate+    (all GND, OCP Table 8) and 1.28 from G for 2nd-mate. B pins on F.Cu, A pins on B.Cu, A under B."""+    name = "OCP_NIC3_SFF_4CPlus_CardEdge"+    L = header(name, "OCP NIC 3.0 SFF primary connector 4C+ gold fingers (SFF-TA-1002 AIC 4C+), 0.6 mm pitch, two-stage mating",+               "OCP NIC 3.0 SFF-TA-1002 4C+ card edge gold finger", "smd exclude_from_pos_files exclude_from_bom allow_soldermask_bridges")+    L.append(text("Reference", "J1", 0, -5.2, "F.SilkS", size=1.0))+    L.append(text("Value", "OCP_NIC3_4C+", 0, -3.8, "F.Fab"))+    L.append(text("Footprint", name, 0, 0, "F.Fab", hide=True))+    pins = ocp_pin_x()+    gnd = set(n for n, net in ocp_pinout().items() if net == "GND")+    for pin, x in pins.items():+        side = "F" if pin.startswith("B") or pin.startswith("OCP_B") else "B"+        first = pin in gnd+        y_top = -3.00+        y_bot = -0.88 if first else -1.28+        cy = (y_top + y_bot) / 2+        L.append(f'  (pad "{pin}" smd rect (at {f(x)} {f(cy)}) (size 0.38 {f(y_bot - y_top)}) (layers "{side}.Cu" "{side}.Mask") (uuid "{u("ocp", pin)}"))')+    # mask opening over the whole finger field (mask not closer than 2.87 mm to datum G)+    for side in ("F", "B"):+        for (a, b) in ((-31.48, -20.4 - 1.125), (-20.4 + 1.125, -0.925), (0.925, 12.26 - 1.375), (12.26 + 1.375, 31.995)):+            L.append(rect(a + 0.05, -2.95, b - 0.05, 0.0, f"{side}.Mask", w=0, fill=True))+    L.append(line(-31.48, -3.05, 31.995, -3.05, "F.Fab", 0.1))+    L.append(rect(-31.48, -7.0, 31.995, 0, "F.CrtYd", 0.05))+    write(name, L)+++def ocp_pin_x():+    """x of every finger relative to datum F (SFF-TA-1002 Fig 5-39)."""+    xs = {}+    for n in range(1, 15):+        xs[f"OCP_B{n}"] = -30.400 + (n - 1) * 0.6+        xs[f"OCP_A{n}"] = -30.400 + (n - 1) * 0.6+    for n in range(1, 71):+        if n <= 28:+            x = -18.205 + (n - 1) * 0.6+        elif n <= 42:+            x = 2.005 + (n - 29) * 0.6+        else:+            x = 14.715 + (n - 43) * 0.6+        xs[f"B{n}"] = x+        xs[f"A{n}"] = x+    return xs+++def ocp_pinout():+    """OCP NIC 3.0 v1.6.0 Table 20, SFF Pin Definition (x16), primary connector 4C+."""+    p = {}+    ocpB = ["NIC_PWR_GOOD0", "MAIN_PWR_EN0", "SCAN0_LD#", "SCAN0_DATA_IN", "SCAN0_DATA_OUT", "SCAN0_CLK", "SLOT_ID0",+            "FLEXIO_0_2", "FLEXIO_0_1", "GND", "REFCLKn2", "REFCLKp2", "GND", "FLEXIO_0_0"]+    ocpA = ["PERST2#", "PERST3#", "WAKE0#", "RBT0_ARB_IN", "RBT0_ARB_OUT", "SLOT_ID1", "FLEXIO_0_3", "FLEXIO_0_5",+            "FLEXIO_0_4", "GND", "REFCLKn3", "REFCLKp3", "GND", "RBT0_CLK_IN"]+    for i in range(14):+        p[f"OCP_B{i+1}"] = ocpB[i]+        p[f"OCP_A{i+1}"] = ocpA[i]+    B = {1: "+12V_EDGE", 2: "+12V_EDGE", 3: "+12V_EDGE", 4: "+12V_EDGE", 5: "+12V_EDGE", 6: "+12V_EDGE",+         7: "BIF0#", 8: "BIF1#", 9: "BIF2#", 10: "PERST0#", 11: "+3.3V_EDGE", 12: "AUX_PWR_EN0", 13: "GND",+         14: "REFCLKn0", 15: "REFCLKp0", 16: "GND", 42: "PRSNTB0#", 68: "RFU1", 69: "RFU2", 70: "PRSNTB3#"}+    A = {1: "GND", 2: "GND", 3: "GND", 4: "GND", 5: "GND", 6: "GND", 7: "SMCLK0", 8: "SMDAT0", 9: "SMRST0#",+         10: "PRSNTA0#", 11: "PERST1#", 12: "PRSNTB2#", 13: "GND", 14: "REFCLKn1", 15: "REFCLKp1", 16: "GND",+         42: "PRSNTB1#", 68: "USB_DAT0n", 69: "USB_DAT0p", 70: "PWRBRK0#"}+    lanes = []  # (n_pin, p_pin, lane)+    starts = [17, 20, 23, 26, 30, 33, 36, 39, 44, 47, 50, 53, 56, 59, 62, 65]+    for lane, s in enumerate(starts):+        B[s] = f"PETn{lane}"; B[s + 1] = f"PETp{lane}"+        A[s] = f"PERn{lane}"; A[s + 1] = f"PERp{lane}"+    for n in range(1, 71):+        p[f"B{n}"] = B.get(n, "GND")+        p[f"A{n}"] = A.get(n, "GND")+    return p+++# ---------------------------------------------------------------- Amphenol G14A42111C12HR+def oculink():+    """Origin: connector centre on the PCB edge, mating face toward -y (off the board), +y into the board.+    Amphenol layout rotated 180 deg: B_n at x = 5.25 - 0.5(n-1), y = 7.37; A_n at x = 4.75 - 0.5(n-1), y = 5.18.+    Pads 0.31 x 0.91. Shell legs: plated slots 0.60 x 1.30 in 1.20 x 1.90 pads at x = +/-6.58, y = 7.05 and 2.26.+    Pegs: NPTH dia 1.00 at (+5.41, 4.46) and (-5.94, 4.46). Body 13.15 wide, 9.00 deep, front 1.98 past the edge."""+    name = "Amphenol_G14A42111C12HR_OCuLink_4X_RA"+    L = header(name, "Amphenol G14A42111C12HR OCuLink 4X (SFF-8611/8621, 42 pos) right-angle receptacle, shell DIP 2.90 mm; land pattern from Amphenol drawing G14A421X1BXXXHR rev D",+               "OCuLink SFF-8611 SFF-8621 PCIe x4 receptacle right angle Amphenol G14", "smd")+    L.append(text("Reference", "J", 0, 10.2, "F.SilkS", size=1.0))+    L.append(text("Value", "G14A42111C12HR", 0, 0.5, "F.Fab"))+    L.append(text("Footprint", name, 0, 0, "F.Fab", hide=True))+    for n in range(1, 22):+        L.append(smd(f"B{n}", 5.25 - 0.5 * (n - 1), 7.37, 0.31, 0.91))+        if n in (1, 21):+            # A1/A21 (PWR, no wire in OCuLink cables) sit 0.07/0.10 mm from the peg NPTH in Amphenol's layout; trim+            # their inner end to y >= 4.875 so the pads clear the peg holes by 0.15 mm (JLC hole clearance)+            L.append(smd(f"A{n}", 4.75 - 0.5 * (n - 1), 5.255, 0.31, 0.76))+        else:+            L.append(smd(f"A{n}", 4.75 - 0.5 * (n - 1), 5.18, 0.31, 0.91))+    for sx in (-6.58, 6.58):+        for sy in (7.05, 2.26):+            L.append(f'  (pad "SH" thru_hole oval (at {f(sx)} {f(sy)}) (size 1.2 1.9) (drill oval 0.6 1.3) (layers "*.Cu" "*.Mask") (uuid "{u("sh", sx, sy)}"))')+    for (px, py) in ((5.41, 4.46), (-5.94, 4.46)):+        L.append(f'  (pad "" np_thru_hole circle (at {f(px)} {f(py)}) (size 1.0 1.0) (drill 1.0) (layers "*.Cu" "*.Mask") (uuid "{u("peg", px)}"))')+    # body outline: 13.15 wide, front face 1.98 past the board edge, 9.00 deep+    x0, x1, y0, y1 = -6.575, 6.575, -1.98, -1.98 + 9.0+    for lay, w in (("F.Fab", 0.1),):+        L.append(rect(x0, y0, x1, y1, lay, w))+    # no body silk: the body edges run through the shell-leg pads and along the contact rows, and the receptacle+    # itself marks its place; the reference designator is the only silk+    L.append(rect(-7.28, -1.98, 7.28, 8.05, "F.CrtYd", 0.05))   # body 13.15 + shell-leg pads (+/-7.18); Amphenol 16.63 keep-out is for other parts+    L.append(line(0, -1.98, 0, -1.4, "F.Fab", 0.1))+    L.append('  (model "${KIPRJMOD}/3d/Amphenol_G14A42111C12HR.step" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))')+    write(name, L)+++def faceplate_mount():+    """OCP SFF faceplate grounding: dia 3.20 PTH, dia 5.50 plated pad on top (Fig. 8), dia 7.00 on the bottom (Fig. 10)."""+    name = "OCP_SFF_Faceplate_Ground_3.2mm"+    L = header(name, "OCP NIC 3.0 SFF faceplate mounting/grounding hole: 3.20 mm PTH, 5.50 mm top pad, 7.00 mm bottom pad (OCP NIC 3.0 v1.6.0 Fig. 8 and 10)",+               "OCP faceplate mounting hole ground", "exclude_from_pos_files exclude_from_bom")+    L.append(text("Reference", "H", 0, -4.2, "F.SilkS", hide=True))+    L.append(text("Value", "Faceplate_GND", 0, 4.2, "F.Fab", hide=True))+    L.append(text("Footprint", name, 0, 0, "F.Fab", hide=True))+    L.append(f'  (pad "1" thru_hole circle (at 0 0) (size 5.5 5.5) (drill 3.2) (layers "*.Cu" "*.Mask") (uuid "{u("fp1")}"))')+    L.append(f'  (pad "1" smd circle (at 0 0) (size 7.0 7.0) (layers "B.Cu" "B.Mask") (uuid "{u("fp2")}"))')+    L.append(f'  (fp_circle (center 0 0) (end 2.85 0) (stroke (width 0.05) (type solid)) (fill no) (layer "F.CrtYd") (uuid "{u("fpc")}"))')+    L.append(f'  (fp_circle (center 0 0) (end 3.6 0) (stroke (width 0.05) (type solid)) (fill no) (layer "B.CrtYd") (uuid "{u("fpcb")}"))')+    write(name, L)+++def testpad_05():+    """0.5 mm round exposed copper test pad, no paste (the adom/testpoint-pad-0-5mm land): a probe spot that sits on+    its net's existing top-layer trace, so it needs no routing of its own."""+    name = "TestPoint_Pad_D0.5mm"+    L = header(name, "0.5 mm round test pad, exposed copper, no paste (adom/testpoint-pad-0-5mm)", "test point pad probe",+               "smd exclude_from_pos_files exclude_from_bom")+    L.append(text("Reference", "TP", 0, -0.9, "F.SilkS", hide=True, size=0.5))+    L.append(text("Value", "TP", 0, 0.9, "F.Fab", hide=True, size=0.5))+    L.append(text("Footprint", name, 0, 0, "F.Fab", hide=True, size=0.5))+    L.append(f'  (pad "1" smd circle (at 0 0) (size 0.5 0.5) (layers "F.Cu" "F.Mask") (uuid "{u("tp05")}"))')+    L.append(f'  (fp_circle (center 0 0) (end 0.4 0) (stroke (width 0.05) (type solid)) (fill no) (layer "F.CrtYd") (uuid "{u("tp05c")}"))')+    write(name, L)+++STD_FP = "/mnt/c/Users/john/AppData/Local/Programs/KiCad/10.0/share/kicad/footprints"+++def chip_nosilk():+    """KiCad 10 stock chip R/C land patterns without their body silk: adjacent 0402 outlines overlap at this board's+    pitch. Pads, courtyard, fab and 3D model are the stock ones, unchanged."""+    import re+    for lib, name in (("Resistor_SMD", "R_0402_1005Metric"), ("Capacitor_SMD", "C_0402_1005Metric"),+                      ("Capacitor_SMD", "C_0603_1608Metric")):+        src = open(os.path.join(STD_FP, lib + ".pretty", name + ".kicad_mod")).read()+        parts = re.split(r"(?=\r?\n\t\()", src)+        keep = [q for q in parts if not (re.match(r"\r?\n\t\((fp_line|fp_poly|fp_arc|fp_circle)\b", q) and '"F.SilkS"' in q)]+        txt = "".join(keep).replace(f'(footprint "{name}"', f'(footprint "{name}_NoSilk"', 1)+        open(os.path.join(OUT, name + "_NoSilk.kicad_mod"), "w").write(txt)+++if __name__ == "__main__":+    ocp_edge(); oculink(); faceplate_mount(); testpad_05(); chip_nosilk()+    print("wrote", sorted(os.listdir(OUT)))
gen/fru.pyadded+103
@@ -0,0 +1,103 @@+#!/usr/bin/env python3+"""FRU EEPROM image for the riser (OCP NIC 3.0 v1.6.0 section 5.10, IPMI Platform Management FRU Information+Storage Definition v1.0): common header, board info area, product info area, multirecord area with the OCP+Base OEM record 0xC0 (Table 66). Writes design/fru.bin (8 KiB, 0xFF padded) and design/fru.txt."""+import os, struct++ROOT = os.path.join(os.path.dirname(__file__), "..")+++def tl(s):+    b = s.encode("ascii")+    assert len(b) < 64+    return bytes([0xC0 | len(b)]) + b+++def area(body):+    body = bytes(body) + b"\xC1"+    n = len(body) + 1+    pad = (-n) % 8+    body += b"\x00" * pad+    body = bytes([body[0], (len(body) + 1) // 8]) + body[2:] if False else body+    return body+++def finish(fixed_head, fields):+    raw = bytearray(fixed_head) + b"".join(fields) + b"\xC1"+    while (len(raw) + 1) % 8:+        raw += b"\x00"+    raw[1] = (len(raw) + 1) // 8          # area length in multiples of 8+    raw += bytes([(-sum(raw)) & 0xFF])+    return bytes(raw)+++def build():+    MFG = "Adom (open hardware)"+    NAME = "OCP3 SFF to 4x OCuLink x4"+    PN = "OCP3-OCULINK4-A"+    SER = "0000000001"+    # board info: version 1, length (patched), language 0 (English), mfg time = minutes since 1996-01-01+    import datetime+    mins = int((datetime.datetime(2026, 10, 12) - datetime.datetime(1996, 1, 1)).total_seconds() // 60)+    board = finish(bytes([1, 0, 0]) + struct.pack("<I", mins)[:3],+                   [tl(MFG), tl(NAME), tl(SER), tl(PN), tl("fru-v1")])+    product = finish(bytes([1, 0, 0]),+                     [tl(MFG), tl(NAME), tl(PN), tl("A"), tl(SER), tl(""), tl("fru-v1")])+    # OCP Base OEM record 0xC0, OEM record version 0x06 (spec 1.6.0)+    rec = bytes([+        0x7F, 0xA6, 0x00,   # OCP IANA 42623, LSB first+        0x06,               # record version: 1.6.0+        0x01, 0x01,         # max power main / aux: under 1 W (logic + two LEDs), rounded up+        0x01, 0x01,         # hot / cold aisle cooling tier with passive cables: tier 1+        0x00,               # passive cooling (no fan)+        0x00, 0x00,         # hot aisle airflow with active cables: passive cables only+        0x00, 0x00,         # cold aisle airflow with active cables: passive cables only+        0x00, 0x00,         # UART config 1/2 (SFF: 0x00)+        0x00,               # no USB on the card edge+        0x00,               # manageability: none+        0x04,               # FRU write protection: mechanical jumper (JP1)+        0x00,               # programming mode not supported+        0x00, 0x00,         # active-cable tiers: passive cables only+        0x00, 0x00,         # transceiver reference power / temperature: passive cable+        0x00,               # thermal tier with fan fail: same (no fan)+        0x00,               # aux power supported ports: none (passive riser)+        0x00, 0x00,         # hot aisle standby airflow, passive cables+        0x00, 0x00,         # cold aisle standby airflow, passive cables+        0x00,               # maximum supported transceiver power+        0xFF,               # reserved+        0x00,               # number of physical (SMBus) controllers N = 0: last byte+    ])+    assert len(rec) == 32+    hdr = bytes([0xC0, 0x82, len(rec), (-sum(rec)) & 0xFF])     # type 0xC0, format 2 | end of list+    hdr += bytes([(-sum(hdr)) & 0xFF])+    multi = hdr + rec+    # common header: offsets in 8-byte units+    off_board = 1+    off_product = off_board + len(board) // 8+    off_multi = off_product + len(product) // 8+    common = bytes([1, 0, 0, off_board, off_product, off_multi, 0])+    common += bytes([(-sum(common)) & 0xFF])+    img = common + board + product + multi+    img += b"\xFF" * (8192 - len(img))+    return img, dict(board=board, product=product, multi=multi, common=common)+++if __name__ == "__main__":+    img, parts = build()+    open(os.path.join(ROOT, "design", "fru.bin"), "wb").write(img)+    used = sum(len(v) for v in parts.values())+    with open(os.path.join(ROOT, "design", "fru.txt"), "w") as fh:+        fh.write("FRU EEPROM image for the OCP NIC 3.0 SFF to 4x OCuLink riser (BR24H64, 8 KiB, 2-byte addressing)\n")+        fh.write(f"used {used} bytes; common header {parts['common'].hex(' ')}\n")+        fh.write(f"board area {len(parts['board'])} B, product area {len(parts['product'])} B, multirecord {len(parts['multi'])} B\n")+        fh.write("OCP Base OEM record 0xC0: " + parts["multi"][5:].hex(" ") + "\n")+        fh.write("Program (JP1 bridged, card in the slot, Linux host): i2ctransfer with 2-byte addressing at 0x50 + SLOT_ID,\n"+                 "or ipmitool fru write <id> fru.bin from the BMC. Remove JP1 afterwards to write-protect.\n")+    print(f"fru.bin {len(img)} bytes, {used} used")+    # self-check: every area checksum sums to zero+    for k, v in parts.items():+        if k == "multi":   # multirecord: 5-byte header sums to zero; data sums to zero with the header's record checksum+            ok = sum(v[:5]) % 256 == 0 and (sum(v[5:]) + v[3]) % 256 == 0+        else:+            ok = sum(v) % 256 == 0+        print(k, "checksum ok" if ok else "BAD", len(v))
gen/gen_schematic.pyadded+361
@@ -0,0 +1,361 @@+#!/usr/bin/env python3+"""Generate kicad/ocp-oculink-riser.kicad_sch from design/netlist.json.++Every pin gets a short wire and a net label (a 168-pin card edge and four 42-pin receptacles read best as labelled+blocks); unused pins get no-connect flags; power nets get PWR_FLAG. Symbols are embedded (lib_symbols): standard+ones copied verbatim from KiCad 10's own libraries, project ones (OCP edge, OCuLink, the ICs) generated here with the+datasheet pin numbers and names."""+import json, os, re, sys, uuid, datetime+sys.path.insert(0, os.path.dirname(__file__))+from footprints import ocp_pinout++HERE = os.path.dirname(__file__)+ROOT = os.path.join(HERE, "..")+KSYM = "/mnt/c/Users/john/AppData/Local/Programs/KiCad/10.0/share/kicad/symbols"+OUT = os.path.join(ROOT, "kicad", "ocp-oculink-riser.kicad_sch")+NS = uuid.UUID("0b8b4a1e-7f6d-4f0e-9d3a-2a6a1c5b9e71")+nl = json.load(open(os.path.join(ROOT, "design", "netlist.json")))+pin_net = {p: n for n, ps in nl["nets"].items() for p in ps}+comp = {c["ref"]: c for c in nl["components"]}+ROOT_UUID = str(uuid.uuid5(NS, "root"))+++def U(*k):+    return str(uuid.uuid5(NS, "/".join(map(str, k))))+++def f(v):+    s = f"{v:.4f}".rstrip("0").rstrip(".")+    return "0" if s in ("", "-0") else s+++def q(s):+    return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'+++# ------------------------------------------------------------------ s-expression extraction from KiCad libs+def extract(libfile, name):+    txt = open(libfile).read()+    m = re.search(r'\n[ \t]*\(symbol "' + re.escape(name) + r'"\s*\n', txt)+    if not m:+        raise SystemExit(f"{name} not in {libfile}")+    i = txt.index("(", m.start())+    depth, j, n = 0, i, len(txt)+    while j < n:+        c = txt[j]+        if c == '"':+            j += 1+            while j < n and txt[j] != '"':+                j += 2 if txt[j] == "\\" else 1+        elif c == "(":+            depth += 1+        elif c == ")":+            depth -= 1+            if depth == 0:+                return txt[i:j + 1]+        j += 1+    raise SystemExit("unbalanced")+++def lib_symbol(lib, name):+    s = extract(os.path.join(KSYM, lib + ".kicad_sym"), name)+    return s.replace(f'(symbol "{name}"', f'(symbol "{lib}:{name}"', 1)+++# ------------------------------------------------------------------ generated symbols+def gen_symbol(libid, refp, left, right, top=(), bottom=(), width=None, units=None, extra_text=None):+    """left/right: lists of (number, name) or None for a gap. Returns (sexp, pin_positions{number:(x,y,angle)})."""+    name = libid.split(":")[1]+    P = 2.54+    if units is None:+        units = [(left, right, top, bottom)]+    out = [f'(symbol {q(libid)} (pin_names (offset 1.016)) (exclude_from_sim no) (in_bom yes) (on_board yes)',+           f'  (property "Reference" {q(refp)} (at 0 0 0) (effects (font (size 1.27 1.27))))',+           f'  (property "Value" {q(name)} (at 0 0 0) (effects (font (size 1.27 1.27))))',+           f'  (property "Footprint" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))',+           f'  (property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))']+    pos = {}+    for ui, (lf, rt, tp, bt) in enumerate(units, start=1):+        n = max(len(lf), len(rt))+        h = (n + 1) * P+        maxlen = max([len(x[1]) for x in lf + rt if x] + [4])+        w = width or max(20.32, round((maxlen * 1.27 * 2 + 6) / P) * P)+        tw = max(len(tp), len(bt))+        w = max(w, (tw + 1) * P)+        x0, y0 = -w / 2, h / 2+        out.append(f'  (symbol {q(name + f"_{ui}_1")}')+        out.append(f'    (rectangle (start {f(x0)} {f(y0)}) (end {f(-x0)} {f(-y0)}) (stroke (width 0.254) (type default)) (fill (type background)))')+        for side, lst in (("L", lf), ("R", rt)):+            for k, pn in enumerate(lst):+                if pn is None:+                    continue+                num, pname = pn+                y = y0 - (k + 1) * P+                if side == "L":+                    x, ang = x0 - P, 0+                else:+                    x, ang = -x0 + P, 180+                out.append(f'    (pin passive line (at {f(x)} {f(y)} {ang}) (length 2.54) (name {q(pname)} (effects (font (size 1.016 1.016)))) (number {q(num)} (effects (font (size 1.016 1.016)))))')+                pos[(ui, num)] = (x, y, ang)+        for side, lst in (("T", tp), ("B", bt)):+            for k, pn in enumerate(lst):+                num, pname = pn+                x = x0 + (k + 1) * P+                if side == "T":+                    y, ang = y0 + P, 270+                else:+                    y, ang = -y0 - P, 90+                out.append(f'    (pin passive line (at {f(x)} {f(y)} {ang}) (length 2.54) (name {q(pname)} (effects (font (size 1.016 1.016)))) (number {q(num)} (effects (font (size 1.016 1.016)))))')+                pos[(ui, num)] = (x, y, ang)+        out.append('  )')+    out.append(')')+    return "\n".join(out), pos+++edge = ocp_pinout()+++def edge_units():+    def pins(prefix, rng):+        return [(f"{prefix}{n}", edge[f"{prefix}{n}"].replace("+3.3V_EDGE", "+3V3_EDGE")) for n in rng]+    u1L = [(f"OCP_B{n}", edge[f"OCP_B{n}"]) for n in range(1, 15)] + [None] + pins("B", range(1, 17)) + [None] + pins("B", (42, 68, 69, 70))+    u1R = [(f"OCP_A{n}", edge[f"OCP_A{n}"]) for n in range(1, 15)] + [None] + pins("A", range(1, 17)) + [None] + pins("A", (42, 68, 69, 70))+    u2L = pins("B", [n for n in range(17, 42)]); u2R = pins("A", [n for n in range(17, 42)])+    u3L = pins("B", range(43, 68)); u3R = pins("A", range(43, 68))+    return [(u1L, u1R, (), ()), (u2L, u2R, (), ()), (u3L, u3R, (), ())]+++def oculink_pins():+    A = {1: "PWR_3V3_RX", 2: "GND", 3: "PERp0", 4: "PERn0", 5: "GND", 6: "PERp1", 7: "PERn1", 8: "GND", 9: "BP_TYPE",+         10: "CWAKE#", 11: "GND", 12: "REFCLK+", 13: "REFCLK-", 14: "GND", 15: "PERp2", 16: "PERn2", 17: "GND",+         18: "PERp3", 19: "PERn3", 20: "GND", 21: "PWR_5V_2"}+    Bm = {1: "PWR_5V_1", 2: "GND", 3: "PETp0", 4: "PETn0", 5: "GND", 6: "PETp1", 7: "PETn1", 8: "GND", 9: "2W_CLK",+          10: "2W_DATA", 11: "GND", 12: "PERST#", 13: "CPRSNT#", 14: "GND", 15: "PETp2", 16: "PETn2", 17: "GND",+          18: "PETp3", 19: "PETn3", 20: "GND", 21: "PWR_3V3_TX"}+    return [(f"A{n}", A[n]) for n in range(1, 22)], [(f"B{n}", Bm[n]) for n in range(1, 22)], (), [("SH", "SHIELD")]+++SYMS = {}+POS = {}+SYMS["Riser:OCP_NIC3_SFF_4C+"], POS["Riser:OCP_NIC3_SFF_4C+"] = gen_symbol("Riser:OCP_NIC3_SFF_4C+", "J", None, None, units=edge_units(), width=50.8)+l, r, t, b = oculink_pins()+SYMS["Riser:OCuLink_4i_Root"], POS["Riser:OCuLink_4i_Root"] = gen_symbol("Riser:OCuLink_4i_Root", "J", l, r, t, b, width=30.48)+SYMS["Riser:SN74LV165A"], POS["Riser:SN74LV165A"] = gen_symbol(+    "Riser:SN74LV165A", "U",+    [("1", "SH/~{LD}"), ("2", "CLK"), ("15", "CLK_INH"), ("10", "SER"), None, ("11", "A"), ("12", "B"), ("13", "C"),+     ("14", "D"), ("3", "E"), ("4", "F"), ("5", "G"), ("6", "H")],+    [("9", "QH"), ("7", "~{QH}")], [("16", "VCC")], [("8", "GND")], width=17.78)+SYMS["Riser:BR24H64FJ"], POS["Riser:BR24H64FJ"] = gen_symbol(+    "Riser:BR24H64FJ", "U", [("1", "A0"), ("2", "A1"), ("3", "A2"), ("7", "WP")], [("5", "SDA"), ("6", "SCL")],+    [("8", "VCC")], [("4", "GND")], width=15.24)+SYMS["Riser:TPS3808G33"], POS["Riser:TPS3808G33"] = gen_symbol(+    "Riser:TPS3808G33", "U", [("5", "SENSE"), ("3", "~{MR}"), ("4", "CT")], [("1", "~{RESET}")],+    [("6", "VDD")], [("2", "GND")], width=15.24)+SYMS["Riser:SN74LVC125A"], POS["Riser:SN74LVC125A"] = gen_symbol(+    "Riser:SN74LVC125A", "U",+    [("2", "1A"), ("1", "~{1OE}"), ("5", "2A"), ("4", "~{2OE}"), ("9", "3A"), ("10", "~{3OE}"), ("12", "4A"), ("13", "~{4OE}")],+    [("3", "1Y"), None, ("6", "2Y"), None, ("8", "3Y"), None, ("11", "4Y")], [("14", "VCC")], [("7", "GND")], width=15.24)+SYMS["Riser:TPD4E05U06"], POS["Riser:TPD4E05U06"] = gen_symbol(+    "Riser:TPD4E05U06", "U", [("1", "D1+"), ("2", "D1-"), ("4", "D2+"), ("5", "D2-")],+    [("10", "NC"), ("9", "NC"), ("7", "NC"), ("6", "NC")], (), [("3", "GND"), ("8", "GND")], width=12.7)+# Adom-style IC symbols (gen/ic_symbols.py -> adom-symbol create, chip outline baked in)+ADOMSYM = os.path.join(ROOT, "kicad", "lib", "adom-symbols")+for name in ("SN74LV165APWR", "BR24H64FJ-5ACE2", "TPS3808G33DBV", "SN74LVC125APWR", "TPD4E05U06QDQARQ1"):+    txt = extract(os.path.join(ADOMSYM, name, name + ".kicad_sym"), name)+    txt = txt.replace(f'(symbol "{name}"', f'(symbol "Riser:{name}"', 1)+    SYMS[f"Riser:{name}"] = txt+    POS[f"Riser:{name}"] = None    # filled below by std_pins+STD = {"R": ("Device", "R"), "C": ("Device", "C"), "LED": ("Device", "LED"), "TP": ("Connector", "TestPoint"),+       "JP": ("Jumper", "SolderJumper_2_Open"), "H": ("Mechanical", "MountingHole_Pad"), "PWR": ("power", "PWR_FLAG")}++# pin positions of standard symbols (read from the library text: (pin ... (at x y a) ... (number "n"+def std_pins(text):+    pos = {}+    for m in re.finditer(r'\(pin \w+ \w+\s*\(at ([-\d.]+) ([-\d.]+) ([-\d.]+)\).*?\(number "([^"]+)"', text, re.S):+        pos[(1, m.group(4))] = (float(m.group(1)), float(m.group(2)), float(m.group(3)))+    return pos+++for key, (lib, name) in STD.items():+    txt = lib_symbol(lib, name)+    SYMS[f"{lib}:{name}"] = txt+    POS[f"{lib}:{name}"] = std_pins(txt)+for k in list(POS):+    if POS[k] is None:+        POS[k] = std_pins(SYMS[k])++SYMOF = {}+for c in nl["components"]:+    r = c["ref"]+    if r == "J1": SYMOF[r] = "Riser:OCP_NIC3_SFF_4C+"+    elif r in ("J2", "J3", "J4", "J5"): SYMOF[r] = "Riser:OCuLink_4i_Root"+    elif r == "U1": SYMOF[r] = "Riser:SN74LV165APWR"+    elif r == "U2": SYMOF[r] = "Riser:BR24H64FJ-5ACE2"+    elif r == "U3": SYMOF[r] = "Riser:TPS3808G33DBV"+    elif r == "U4": SYMOF[r] = "Riser:SN74LVC125APWR"+    elif r in ("U5", "U6", "U7", "U8"): SYMOF[r] = "Riser:TPD4E05U06QDQARQ1"+    elif r.startswith("R"): SYMOF[r] = "Device:R"+    elif r.startswith("C"): SYMOF[r] = "Device:C"+    elif r.startswith("D"): SYMOF[r] = "Device:LED"+    elif r.startswith("TP"): SYMOF[r] = "Connector:TestPoint"+    elif r.startswith("JP"): SYMOF[r] = "Jumper:SolderJumper_2_Open"+    elif r.startswith("H"): SYMOF[r] = "Mechanical:MountingHole_Pad"++# ------------------------------------------------------------------ placement on an A1 sheet (mm, y down; 2.54 grid)+G = 2.54+def g(v):+    return round(v / G) * G++PLACE = {}      # ref -> list of (unit, x, y, rot)+PLACE["J1"] = [(1, g(60), g(110), 0), (2, g(60), g(285), 0), (3, g(60), g(430), 0)]+for k in range(4):+    PLACE[f"J{2+k}"] = [(1, g(470 + 95 * k), g(105), 0)]+PLACE["U1"] = [(1, g(210), g(95), 0)]+PLACE["U2"] = [(1, g(210), g(190), 0)]+PLACE["U3"] = [(1, g(320), g(95), 0)]+PLACE["U4"] = [(1, g(320), g(190), 0)]+for k in range(4):+    PLACE[f"U{5+k}"] = [(1, g(470 + 95 * k), g(235), 0)]+# two-pin parts: vertical (rot 90) in rows under their blocks+row = {"scan": (g(175), g(150)), "fru": (g(175), g(240)), "power": (g(285), g(140)), "perst": (g(285), g(255)),+       "host": (g(175), g(285)), "test": (g(285), g(320))}+two = [c for c in nl["components"] if c["ref"] not in PLACE and c["ref"] not in ("H1", "H2")]+cursor = {}+for c in two:+    blk = c["block"]+    if blk.startswith("port"):+        k = int(blk[-1]); base = (g(452 + 95 * k), g(285))+    elif blk == "mech":+        base = (g(285), g(380))+    else:+        base = row.get(blk, (g(285), g(380)))+    n = cursor.get(base, 0); cursor[base] = n + 1+    PLACE[c["ref"]] = [(1, base[0] + g(15.24) * (n % 6), base[1] + g(22.86) * (n // 6), 0)]+PLACE["H1"] = [(1, g(400), g(400), 0)]+PLACE["H2"] = [(1, g(420), g(400), 0)]++# ------------------------------------------------------------------ emit+items = []+labels_at = set()+++def pin_world(libid, unit, num, x, y, rot):+    px, py, ang = POS[libid][(unit, num)] if (unit, num) in POS[libid] else POS[libid][(1, num)]+    # symbol coords are y-up; rotation not used (all 0)+    return x + px, y - py, ang+++def wire(x1, y1, x2, y2):+    items.append(f'  (wire (pts (xy {f(x1)} {f(y1)}) (xy {f(x2)} {f(y2)})) (stroke (width 0) (type default)) (uuid {q(U("w", x1, y1, x2, y2))}))')+++def label(name, x, y, ang):+    just = "left" if ang in (0, 270) else "right"+    items.append(f'  (global_label {q(name)} (shape passive) (at {f(x)} {f(y)} {ang}) (fields_autoplaced yes) (effects (font (size 1.016 1.016)) (justify {just})) (uuid {q(U("l", name, x, y))})\n    (property "Intersheetrefs" "${{INTERSHEET_REFS}}" (at {f(x)} {f(y)} 0) (effects (font (size 1.016 1.016)) (hide yes))))')+++def noconn(x, y):+    items.append(f'  (no_connect (at {f(x)} {f(y)}) (uuid {q(U("nc", x, y))}))')+++syms_out = []+for c in nl["components"]:+    ref = c["ref"]; libid = SYMOF[ref]+    for (unit, x, y, rot) in PLACE[ref]:+        # snap: shift the instance so its pin ends land on the 2.54 mm grid (world x = X + px, world y = Y - py)+        _pp = [v for (u_, n_), v in POS[libid].items() if u_ == unit]+        if _pp:+            px0, py0 = _pp[0][0], _pp[0][1]+            x = x + ((-px0) % G if abs((-px0) % G - G) > 1e-6 else 0.0) - 0.0+            y = y + (py0 % G if abs(py0 % G - G) > 1e-6 else 0.0)+            x, y = round(x, 4), round(y, 4)+        props = [f'    (property "Reference" {q(ref)} (at {f(x)} {f(y - 3)} 0) (effects (font (size 1.27 1.27))))',+                 f'    (property "Value" {q(c["value"])} (at {f(x)} {f(y + 3)} 0) (effects (font (size 1.27 1.27))))',+                 f'    (property "Footprint" {q(c["fp"])} (at {f(x)} {f(y)} 0) (effects (font (size 1.27 1.27)) (hide yes)))',+                 f'    (property "Datasheet" "" (at {f(x)} {f(y)} 0) (effects (font (size 1.27 1.27)) (hide yes)))',+                 f'    (property "MPN" {q(c.get("mpn",""))} (at {f(x)} {f(y)} 0) (effects (font (size 1.27 1.27)) (hide yes)))',+                 f'    (property "Manufacturer" {q(c.get("mfr",""))} (at {f(x)} {f(y)} 0) (effects (font (size 1.27 1.27)) (hide yes)))',+                 f'    (property "Description" {q(c.get("desc",""))} (at {f(x)} {f(y)} 0) (effects (font (size 1.27 1.27)) (hide yes)))']+        if libid.startswith(("Device:", "Connector:", "Jumper:", "Mechanical:")):+            props[0] = f'    (property "Reference" {q(ref)} (at {f(x + 2.54)} {f(y - 1.27)} 0) (effects (font (size 1.27 1.27)) (justify left)))'+            props[1] = f'    (property "Value" {q(c["value"])} (at {f(x + 2.54)} {f(y + 1.27)} 0) (effects (font (size 1.27 1.27)) (justify left)))'+        pins_here = [num for (u_, num) in POS[libid] if u_ == unit]+        pinlines = "\n".join(f'    (pin {q(num)} (uuid {q(U("pin", ref, num))}))' for num in sorted(set(pins_here)))+        inbom = "no" if ref.startswith(("TP", "JP", "H")) or ref == "J1" else "yes"+        syms_out.append(f'  (symbol (lib_id {q(libid)}) (at {f(x)} {f(y)} {rot}) (unit {unit}) (exclude_from_sim no) (in_bom {inbom}) (on_board yes) (dnp no) (fields_autoplaced yes) (uuid {q(U("sym", ref, unit))})\n'+                        + "\n".join(props) + "\n" + pinlines ++                        f'\n    (instances (project "ocp-oculink-riser" (path {q("/" + ROOT_UUID)} (reference {q(ref)}) (unit {unit}))))\n  )')+        for num in sorted(set(pins_here)):+            wx, wy, ang = pin_world(libid, unit, num, x, y, rot)+            net = pin_net.get(f"{ref}.{num}")+            # direction away from the body+            d = {0: (-1, 0), 180: (1, 0), 90: (0, 1), 270: (0, -1)}[int(ang) % 360]+            ex, ey = wx + d[0] * G, wy + d[1] * G+            if net is None:+                noconn(wx, wy)+                continue+            wire(wx, wy, ex, ey)+            lang = {(-1, 0): 180, (1, 0): 0, (0, 1): 270, (0, -1): 90}[d]+            label(net, ex, ey, lang)++# PWR_FLAG on the off-board rails+pw = []+for i, net in enumerate(("GND", "+3V3_EDGE", "+12V_EDGE")):+    x, y = g(400 + 20 * i), g(440)+    syms_out.append(f'  (symbol (lib_id "power:PWR_FLAG") (at {f(x)} {f(y)} 0) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid {q(U("pwr", net))})\n'+                    f'    (property "Reference" {q(f"#FLG0{i+1}")} (at {f(x)} {f(y - 3)} 0) (effects (font (size 1.27 1.27)) (hide yes)))\n'+                    f'    (property "Value" "PWR_FLAG" (at {f(x)} {f(y - 4.5)} 0) (effects (font (size 1.27 1.27))))\n'+                    f'    (property "Footprint" "" (at {f(x)} {f(y)} 0) (effects (font (size 1.27 1.27)) (hide yes)))\n'+                    f'    (property "Datasheet" "" (at {f(x)} {f(y)} 0) (effects (font (size 1.27 1.27)) (hide yes)))\n'+                    f'    (pin "1" (uuid {q(U("pwrpin", net))}))\n'+                    f'    (instances (project "ocp-oculink-riser" (path {q("/" + ROOT_UUID)} (reference {q(f"#FLG0{i+1}")}) (unit 1))))\n  )')+    wire(x, y, x, y + G)+    label(net, x, y + G, 270)++# section titles and the design note+notes = [+    (g(30), g(20), "OCP NIC 3.0 SFF (Supermicro AIOM) to 4x OCuLink 4i riser - passive, PCIe Gen4 x16 as 4 x4", 3.0),+    (g(30), g(30), "Card edge J1: OCP NIC 3.0 v1.6.0 Table 20 (4C+). PRSNTB[3:0]# = 0b0011 -> 4 x4 card (Table 45). Host BIOS: set the AIOM slot to x4x4x4x4.", 1.8),+    (g(30), g(36), "Port k = PCIe lanes 4k..4k+3 with REFCLKk and PERSTk# (Table 47). OCuLink pinout: SFF-9402 Table 6-1, root side.", 1.8),+    (g(30), g(42), "No AC caps: host TX caps are on the baseboard, device TX caps are on the device. 85 ohm pairs, JLC06161H-3313 stack, see docs.", 1.8),+    (g(175), g(70), "Scan chain SR0 (mandatory, OCP 4.5.6)", 2.0),+    (g(175), g(170), "FRU EEPROM at 0xA0 + SLOT_ID (OCP 5.10)", 2.0),+    (g(285), g(70), "NIC_PWR_GOOD (OCP 4.5.7.6)", 2.0),+    (g(285), g(170), "PERST# buffers to the ports", 2.0),+    (g(470), g(70), "OCuLink ports (SFF-8611/8621), root side", 2.0),+    (g(470), g(215), "Port sideband ESD and termination", 2.0),+    (g(30), g(70), "Gold fingers - sideband and power", 2.0),+    (g(30), g(250), "Gold fingers - PCIe lanes 0-7", 2.0),+    (g(30), g(395), "Gold fingers - PCIe lanes 8-15", 2.0),+]+for (x, y, s, sz) in notes:+    items.append(f'  (text {q(s)} (exclude_from_sim no) (at {f(x)} {f(y)} 0) (effects (font (size {sz} {sz}) (thickness {f(sz*0.15)}) (bold yes)) (justify left bottom)) (uuid {q(U("t", s))}))')++today = datetime.date(2026, 10, 12).isoformat()+doc = ['(kicad_sch (version 20231120) (generator "riser-gen") (generator_version "1.0")',+       f'  (uuid {q(ROOT_UUID)})', '  (paper "A1")',+       f'  (title_block (title "OCP NIC 3.0 SFF to 4x OCuLink riser") (date {q(today)}) (rev "A") (company "Adom") (comment 1 "Generated from design/netlist.json by gen/gen_schematic.py"))',+       '  (lib_symbols']+for k, v in SYMS.items():+    doc.append("    " + v.replace("\n", "\n    "))+doc.append('  )')+doc += items+doc += syms_out+doc.append('  (sheet_instances (path "/" (page "1")))')+doc.append(')')+open(OUT, "w").write("\n".join(doc) + "\n")+print("wrote", OUT, len(syms_out), "symbol instances")++# ------------------------------------------------------------------ project symbol library and lib tables+lib = ['(kicad_symbol_lib (version 20231120) (generator "riser-gen") (generator_version "1.0")']+for k, v in SYMS.items():+    if k.startswith("Riser:"):+        lib.append("  " + v.replace(f'(symbol "{k}"', f'(symbol "{k.split(":")[1]}"', 1).replace("\n", "\n  "))+lib.append(")")+os.makedirs(os.path.join(ROOT, "kicad", "lib"), exist_ok=True)+open(os.path.join(ROOT, "kicad", "lib", "Riser.kicad_sym"), "w").write("\n".join(lib) + "\n")+open(os.path.join(ROOT, "kicad", "sym-lib-table"), "w").write(+    '(sym_lib_table\n  (version 7)\n  (lib (name "Riser")(type "KiCad")(uri "${KIPRJMOD}/lib/Riser.kicad_sym")(options "")(descr "OCP OCuLink riser project symbols"))\n)\n')+open(os.path.join(ROOT, "kicad", "fp-lib-table"), "w").write(+    '(fp_lib_table\n  (version 7)\n  (lib (name "Riser")(type "KiCad")(uri "${KIPRJMOD}/lib/Riser.pretty")(options "")(descr "OCP OCuLink riser project footprints"))\n)\n')+print("wrote Riser.kicad_sym and lib tables")
gen/hs.pyadded+559
@@ -0,0 +1,559 @@+#!/usr/bin/env python3+"""High-speed routing for the OCP NIC 3.0 SFF -> 4x OCuLink riser.++Board frame: x across the card (0..76), y from the faceplate edge (0) to the gold-finger edge (115), KiCad y-down.+Layers: L1=F.Cu, L2=In1 GND, L3=In2, L4=In3 GND, L5=In4, L6=B.Cu GND (and the A-side fingers).++Each differential pair is a centreline of nodes (x, y, layer). Consecutive nodes on one layer are a trace; a layer+change is a via pair placed across the direction of travel. The n trace is the left offset (west when heading north),+p the right one; that holds the whole way because every path is an octilinear S-shape.++Lane reversal (OCuLink ports face the faceplate, so each port sees its lanes in the opposite order to the fingers):+  stage A (just past the via row): lanes 0<->1 and 2<->3 cross; lane 0 on L1, lane 1 on L3, lane 3 on L1, lane 2 on L5+  lane 3 drops L1 -> L5 in band M+  stage B (before the connector): block {1,0} (L3, L1) crosses block {3,2} (both L5)+"""+import math, json, os, sys+sys.path.insert(0, os.path.dirname(__file__))+from footprints import ocp_pin_x++F = 38.0 - 0.258          # datum F: H (card centre line, x=38) minus 0.258 (SFF-TA-1002 Fig 5-39, OCP Detail A)+EDGE_Y = 115.0+PAD_TOP = EDGE_Y - 3.00   # inner end of every gold finger+CX = [16.1 + 14.6 * k for k in range(4)]   # OCuLink port centres (pitch 14.6, inside the 59.1 mm faceplate zone)++# 85 ohm differential, JLC06161H-3313 (design/zsolve.py, 2D field solve)+PAIR = {"L1": (0.163, 0.15), "L6": (0.163, 0.15), "L3": (0.131, 0.15), "L5": (0.121, 0.15)}+KLAY = {"L1": "F.Cu", "L3": "In2.Cu", "L5": "In4.Cu", "L6": "B.Cu"}+VIA_P = 0.70               # signal via pair pitch+VIA_D, VIA_H = 0.40, 0.20  # transition vias+VIP_D, VIP_H = 0.25, 0.15  # via-in-pad at the OCuLink pads (JLC 6L: filled and capped by default)+++def pitch(layer):+    w, s = PAIR[layer]+    return w + s+++class Pair:+    def __init__(self, name, nnet, pnet, kind):+        self.name, self.nnet, self.pnet, self.kind = name, nnet, pnet, kind+        self.nodes = []          # (x, y, layer)+        self.start = None        # ((xn, yn), (xp, yp), layer)  pad ends+        self.end = None          # ((xn, yn), (xp, yp), layer, via_in_pad)+        self.tracks = []         # (layer, net, x1, y1, x2, y2, w)+        self.vias = []           # (x, y, net, d, h)++    def add(self, x, y, layer):+        self.nodes.append((round(x, 4), round(y, 4), layer))+        return self+++def unit(dx, dy):+    L = math.hypot(dx, dy)+    return (dx / L, dy / L)+++def left(d):+    return (d[1], -d[0])+++def offset_polyline(pts, off):+    """Mitred offset of an octilinear polyline (left side positive)."""+    out = []+    n = len(pts)+    for i in range(n):+        if i == 0:+            d = unit(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]); l = left(d)+            out.append((pts[0][0] + l[0] * off, pts[0][1] + l[1] * off))+        elif i == n - 1:+            d = unit(pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]); l = left(d)+            out.append((pts[-1][0] + l[0] * off, pts[-1][1] + l[1] * off))+        else:+            d1 = unit(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1])+            d2 = unit(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])+            l1, l2 = left(d1), left(d2)+            bis = (l1[0] + l2[0], l1[1] + l2[1])+            bl = math.hypot(*bis)+            if bl < 1e-9:+                raise ValueError("reversal in path")+            bis = (bis[0] / bl, bis[1] / bl)+            cosh = bis[0] * l1[0] + bis[1] * l1[1]+            k = off / cosh+            out.append((pts[i][0] + bis[0] * k, pts[i][1] + bis[1] * k))+    return out+++def build(pair):+    """Turn a pair's centreline nodes into n/p tracks and vias."""+    nodes = []+    for nd in pair.nodes:+        if nodes and abs(nd[0] - nodes[-1][0]) < 1e-6 and abs(nd[1] - nodes[-1][1]) < 1e-6 and nd[2] == nodes[-1][2]:+            continue+        nodes.append(nd)+    # drop collinear middle points on the same layer+    clean = [nodes[0]]+    for i in range(1, len(nodes) - 1):+        a, b, c = clean[-1], nodes[i], nodes[i + 1]+        if a[2] == b[2] == c[2]:+            cr = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])+            dot = (b[0] - a[0]) * (c[0] - b[0]) + (b[1] - a[1]) * (c[1] - b[1])+            if abs(cr) < 1e-9 and dot > 0:+                continue+        clean.append(b)+    clean.append(nodes[-1])+    nodes = clean+    pair.nodes = nodes+    # split into runs per layer+    runs = []+    cur = [nodes[0]]+    for nd in nodes[1:]:+        if nd[2] == cur[-1][2]:+            cur.append(nd)+        else:+            runs.append(cur)+            cur = [nd]+    runs.append(cur)+    vias = []  # positions between runs: last node of run i == first node xy of run i+1+    for i in range(len(runs) - 1):+        a = runs[i][-1]; b = runs[i + 1][0]+        assert abs(a[0] - b[0]) < 1e-6 and abs(a[1] - b[1]) < 1e-6, (pair.name, a, b)+    for ri, run in enumerate(runs):+        layer = run[0][2]+        p = pitch(layer); w = PAIR[layer][0]+        pts = [(x, y) for (x, y, _) in run]+        # entry and exit fans to via pitch+        fan_in = ri > 0+        fan_out = ri < len(runs) - 1+        for sign, net in ((+1, pair.nnet), (-1, pair.pnet)):+            off = sign * p / 2+            line = offset_polyline(pts, off)+            if fan_in:+                d = unit(pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]); l = left(d)+                vx, vy = pts[0][0] + l[0] * sign * VIA_P / 2, pts[0][1] + l[1] * sign * VIA_P / 2+                f = (VIA_P - p) / 2+                line[0] = (line[0][0] + d[0] * f, line[0][1] + d[1] * f)+                line.insert(0, (vx, vy))+            if fan_out:+                d = unit(pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]); l = left(d)+                vx, vy = pts[-1][0] + l[0] * sign * VIA_P / 2, pts[-1][1] + l[1] * sign * VIA_P / 2+                f = (VIA_P - p) / 2+                line[-1] = (line[-1][0] - d[0] * f, line[-1][1] - d[1] * f)+                line.append((vx, vy))+            # pad ends+            if ri == 0 and pair.start is not None:+                padpt = pair.start[0] if sign > 0 else pair.start[1]+                line = pad_join(padpt, line, at_start=True, p=p)+            if ri == len(runs) - 1 and pair.end is not None:+                padpt = pair.end[0] if sign > 0 else pair.end[1]+                line = pad_join(padpt, line, at_start=False, p=p)+            for (x1, y1), (x2, y2) in zip(line, line[1:]):+                if math.hypot(x2 - x1, y2 - y1) > 1e-6:+                    pair.tracks.append((layer, net, x1, y1, x2, y2, w))+        if fan_out:+            d = unit(pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]); l = left(d)+            for sign, net in ((+1, pair.nnet), (-1, pair.pnet)):+                pair.vias.append((pts[-1][0] + l[0] * sign * VIA_P / 2, pts[-1][1] + l[1] * sign * VIA_P / 2, net, VIA_D, VIA_H))+    if pair.end is not None and pair.end[3]:+        for (pt, net) in ((pair.end[0], pair.nnet), (pair.end[1], pair.pnet)):+            pair.vias.append((pt[0], pt[1], net, VIP_D, VIP_H))+    return pair+++def pad_join(padpt, line, at_start, p):+    """Join a trace end to a pad point with a 45 degree jog (pads are on the pair's axis line)."""+    if at_start:+        q = line[0]+        dx, dy = q[0] - padpt[0], q[1] - padpt[1]+        # go straight along the major axis, then 45 degrees+        if abs(dx) < 1e-6:+            return [padpt] + line+        s = 1 if dy > 0 else -1+        mid = (padpt[0], q[1] - s * abs(dx))+        return [padpt, mid] + line+    else:+        q = line[-1]+        dx, dy = padpt[0] - q[0], padpt[1] - q[1]+        if abs(dx) < 1e-6:+            return line + [padpt]+        s = 1 if dy > 0 else -1+        mid = (padpt[0], q[1] + s * abs(dx)) if abs(dy) > abs(dx) else None+        # move diagonally first, then straight onto the pad+        mid = (q[0] + dx, q[1] + s * abs(dx))+        return line + [mid, padpt]+++# ------------------------------------------------------------------------------------------ geometry plan+PX = ocp_pin_x()+++def finger(pin):+    return (F + PX[pin], PAD_TOP)+++Y_FAN0 = 107.6      # fan diagonals start (clear of the key slots, which end at y = 108.0)+Y_VIA = 97.6        # transition via row+Y_SA0 = 96.9        # stage A start+LANE_PITCH = 2.8+WITHIN = 0.7        # P/R offset from the lane cell centre after the via row+# lane cell centres at the via row+XC = []+x = 21.0+for k in range(4):+    row = []+    for l in range(4):+        row.append(x + l * LANE_PITCH)+    XC.append(row)+    x = row[-1] + LANE_PITCH + 1.5++# which side of its cell each pair sits on after the via row (P first, R second), per lane index in port+#   b0: P west / R east, b1: R west / P east, b2: P west / R east, b3: R west / P east+SIDE = {0: (-1, +1), 1: (+1, -1), 2: (-1, +1), 3: (+1, -1)}+# layer of each bundle in band S and at the connector+LAYER_S = {0: "L1", 1: "L3", 2: "L5", 3: "L1"}+LAYER_C = {0: "L1", 1: "L3", 2: "L5", 3: "L5"}++# connector-end pair centres relative to the port centre, and how each pair ends+#   P pairs land on B-row pads, R pairs pass the B row through a gap and land on A-row pads+CONN = {+    # lane: (P centre, R gap x, R pad centre)+    0: (4.0, 4.0, 3.5),+    1: (2.5, 1.0, 2.0),+    2: (-2.0, -0.5, -2.5),+    3: (-3.5, -5.15, -4.0),+}+Y_BROW, Y_AROW = 7.37, 5.18+Y_STRIP = 6.27          # between the A and B rows, where R pairs turn to their A pads+Y_NB_END = 13.0         # stage B finished: pairs at their connector x+Y_NB = 19.5             # stage B starts (block swap, 45 degree)+++def lane_pins(lane):+    starts = [17, 20, 23, 26, 30, 33, 36, 39, 44, 47, 50, 53, 56, 59, 62, 65]+    s = starts[lane]+    return f"{s}", f"{s+1}"+++# band M straight-section positions relative to the port centre (before the band-M2 shift back west)+#   order W->E: R1, P1, P0, R0 | crossing-via gap | R3, P3, P2, R2+XEND = {("R", 1): 1.0, ("P", 1): 2.5, ("P", 0): 4.0, ("R", 0): 5.2,+        ("R", 3): -5.15, ("P", 3): -3.5, ("P", 2): -2.0, ("R", 2): -0.5}+D_SWAP = 6.0+def xm_rel(kind, l):+    x = XEND[(kind, l)]+    return x - D_SWAP if l in (0, 1) else x + D_SWAP++S_EAST = 6.0            # band M corridors sit this far east of their connectors+Y_SW = 93.4             # lane 3 drops L1 -> L5 here+Y_M0 = 92.6             # band M diagonals start+Y_M2 = 36.0             # band M2: shift back west by S_EAST+Y_NB = Y_NB_END + D_SWAP+++def plan_lane(k, l):+    """(P pair, R pair) for port k, port-lane l, finger to OCuLink pad."""+    lane = 4 * k + l+    n, p = lane_pins(lane)+    P = Pair(f"P{k}{l}", f"PETn{lane}", f"PETp{lane}", "PET")+    R = Pair(f"R{k}{l}", f"PERn{lane}", f"PERp{lane}", "PER")+    cx = CX[k]+    xc_f = F + PX["B" + n] + 0.3+    sP, sR = SIDE[l]+    partner = {0: 1, 1: 0, 2: 3, 3: 2}[l]+    for pr, kind, s_side, startlayer in ((P, "P", sP, "L1"), (R, "R", sR, "L6")):+        xv = XC[k][l] + s_side * WITHIN+        xa = XC[k][partner] + s_side * WITHIN+        xm = cx + S_EAST + xm_rel(kind, l)+        xn = cx + xm_rel(kind, l)+        xe = cx + XEND[(kind, l)]+        pin_n = ("B" if kind == "P" else "A") + n+        pin_p = ("B" if kind == "P" else "A") + p+        pr.start = (finger(pin_n), finger(pin_p), startlayer)+        pc = pitch(startlayer)+        y0 = PAD_TOP - 0.25 - (0.6 - pc) / 2+        pr.add(xc_f, y0, startlayer)+        pr.add(xc_f, Y_FAN0, startlayer)+        pr.add(xv, Y_FAN0 - abs(xv - xc_f), startlayer)+        lay = LAYER_S[l]+        pr.add(xv, Y_VIA, startlayer)+        if lay != startlayer:+            pr.add(xv, Y_VIA, lay)+        pr.add(xv, Y_SA0, lay)+        pr.add(xa, Y_SA0 - abs(xa - xv), lay)+        if l == 3:                      # lane 3 drops to L5 for stage B+            pr.add(xa, Y_SW, lay)+            pr.add(xa, Y_SW, "L5")+            lay = "L5"+        pr.add(xa, Y_M0, lay)+        pr.add(xm, Y_M0 - abs(xm - xa), lay)+        for (tx, ty) in TUNE.get(pr.name, []):+            pr.add(tx, ty, lay)+        pr.add(xm, Y_M2, lay)+        pr.add(xn, Y_M2 - abs(xn - xm), lay)+        pr.add(xn, Y_NB, lay)+        pr.add(xe, Y_NB_END, lay)+        connector_end(pr, kind, l, cx, lay)+    return P, R+++def connector_end(pr, kind, l, cx, lay):+    xe = cx + XEND[(kind, l)]+    if kind == "P":+        pads = (cx + 5.25 - 0.5 * (b_pin(l, "n") - 1), cx + 5.25 - 0.5 * (b_pin(l, "p") - 1))+        xc = (pads[0] + pads[1]) / 2+        assert abs(xc - xe) < 1e-6, (pr.name, xc, xe)+        pr.add(xe, Y_BROW + 0.45, lay)+        pr.end = ((pads[0], Y_BROW), (pads[1], Y_BROW), lay, lay != "L1")+        return+    # R pairs: L1 (lane 0) drops to L3 first, then the B-row gap, the strip, the A pads+    if lay == "L1":+        yv = 11.6+        pr.add(xe, yv, "L1")+        pr.add(xe, yv, "L3")+        lay = "L3"+        xg = cx + CONN[l][1]+        pr.add(xe, yv - 0.6, lay)+        pr.add(xg, yv - 0.6 - abs(xg - xe), lay)+    xg = cx + CONN[l][1]+    xa = cx + CONN[l][2]+    pads = (cx + 4.75 - 0.5 * (a_pin(l, "n") - 1), cx + 4.75 - 0.5 * (a_pin(l, "p") - 1))+    assert abs((pads[0] + pads[1]) / 2 - xa) < 1e-6, (pr.name, pads, xa)+    c = min(0.3, abs(xa - xg) / 2)+    sgn = 1 if xa > xg else -1+    pr.add(xg, Y_STRIP + c, lay)+    pr.add(xg + sgn * c, Y_STRIP, lay)+    pr.add(xa - sgn * c, Y_STRIP, lay)+    pr.add(xa, Y_STRIP - c, lay)+    pr.add(xa, Y_AROW + 0.42, lay)+    pr.end = ((pads[0], Y_AROW), (pads[1], Y_AROW), lay, True)+++def b_pin(l, pol):+    return {0: {"p": 3, "n": 4}, 1: {"p": 6, "n": 7}, 2: {"p": 15, "n": 16}, 3: {"p": 18, "n": 19}}[l][pol]+++def a_pin(l, pol):+    return {0: {"p": 3, "n": 4}, 1: {"p": 6, "n": 7}, 2: {"p": 15, "n": 16}, 3: {"p": 18, "n": 19}}[l][pol]+++TUNE = {}   # pair name -> centreline points inserted in band M (filled by tune())++# band-M tuning windows and direction (away from same-layer neighbours)+TUNE_DIR = {("P", 0): -1, ("R", 0): +1, ("R", 1): -1, ("P", 1): +1,+            ("R", 3): -1, ("P", 3): +1, ("P", 2): -1, ("R", 2): +1}+# y windows (top, bottom) so that P3 (east bumps) and P2 (west bumps) never bulge at the same height+TUNE_WIN = {("P", 0): (56.0, 38.0), ("R", 0): (56.0, 38.0), ("R", 1): (77.0, 58.0), ("P", 1): (77.0, 58.0),+            ("R", 3): (77.0, 58.0), ("P", 3): (77.0, 67.6), ("P", 2): (67.0, 58.0), ("R", 2): (77.0, 58.0)}+++def accordion(x0, ytop, side, amp, n, g=1.1, c=0.4):+    """Centreline points of n accordion periods heading north from (x0, ytop) (y decreasing)."""+    pts = []+    y = ytop+    x1 = x0 + side * amp+    for i in range(n):+        y1 = y; y2 = y - g; y3 = y - 2 * g+        pts += [(x0, y1 + c), (x0 + side * c, y1), (x1 - side * c, y1), (x1, y1 - c),+                (x1, y2 + c), (x1 - side * c, y2), (x0 + side * c, y2), (x0, y2 - c)]+        y = y3+    return pts+++def bumps(x0, ytop, side, amp, n, flat=0.4, gap=0.5):+    pts = []+    y = ytop+    for i in range(n):+        pts += [(x0, y), (x0 + side * amp, y - amp), (x0 + side * amp, y - amp - flat), (x0, y - 2 * amp - flat)]+        y = y - 2 * amp - flat - gap+    return pts+++def tune(iterations=4, tol_ps=0.3):+    """Delay-match each port's TX lanes and RX lanes (by ps, including via barrels)."""+    import delay+    TUNE.clear()+    for it in range(iterations):+        pairs = [build(pp) for k in range(4) for l in range(4) for pp in plan_lane(k, l)]+        byname = {pp.name: pp for pp in pairs}+        changed = False+        for k in range(4):+            for kind in "PR":+                grp = [byname[f"{kind}{k}{l}"] for l in range(4)]+                d = {pp.name: delay.pair_delay(pp) for pp in grp}+                tgt = max(d.values())+                for pp in grp:+                    l = int(pp.name[2])+                    need_ps = tgt - d[pp.name]+                    if need_ps < tol_ps:+                        continue+                    lay = "L5" if l == 3 else LAYER_S[l]+                    need_mm = need_ps / delay.per_mm(lay)+                    cur = TUNE.get(pp.name)+                    xm = CX[k] + S_EAST + xm_rel(kind, l)+                    ytop, ybot = TUNE_WIN[(kind, l)]+                    side = TUNE_DIR[(kind, l)]+                    # current extra length of the existing tuning, then add the shortfall+                    have = getattr(tune, "extra", {}).get(pp.name, 0.0)+                    want = have + need_mm+                    if l == 0:+                        n = max(1, math.ceil(want / 4.6))+                        amp = want / (2 * n) + 0.234   # chamfer correction: 4 corners x 0.586c / (2 per period)+                        pts = accordion(xm, ytop, side, amp, n)+                    else:+                        n = max(1, math.ceil(want / 0.45))+                        amp = want / (0.828 * n)+                        pts = bumps(xm, ytop, side, amp, n)+                    if pts[-1][1] < ybot:+                        raise SystemExit(f"tuning for {pp.name} overruns its window: {pts[-1][1]:.1f} < {ybot}")+                    TUNE[pp.name] = pts+                    tune.extra = getattr(tune, "extra", {})+                    tune.extra[pp.name] = want+                    changed = True+        if not changed:+            break+    return TUNE+++def all_lanes():+    out = []+    for k in range(4):+        for l in range(4):+            P, R = plan_lane(k, l)+            out += [build(P), build(R)]+    return out+++# ------------------------------------------------------------------------------------------ refclks and the band-M bus+Y_BUS = {"PERST1": 80.0, "REFCLK2": 81.5, "PERST2": 83.0, "REFCLK3": 84.5, "PERST3": 86.0, "REFCLK1": 87.5}+X_CORR_VIA = lambda k: CX[k] + S_EAST + 0.025          # in the R0-R3 gap of corridor k (band M)+G_CONT, G_PERST, G_REF = 12.35, 13.15, 14.4            # positions in gap (k-1, k), relative to cx[k-1] (band M)+Y_REF_TURN, Y_PERST_TURN = 12.3, 10.9+BUS_START_X = 16.0                                      # PERST bus lines start here (L1 -> L5 via)+++def corner_path(pts_xy, layer, c=0.3):+    """Expand a rectilinear list of points into chamfered octilinear nodes (45 degree chamfer c)."""+    out = [pts_xy[0]]+    for i in range(1, len(pts_xy) - 1):+        a, b, cc = pts_xy[i - 1], pts_xy[i], pts_xy[i + 1]+        d1 = unit(b[0] - a[0], b[1] - a[1]); d2 = unit(cc[0] - b[0], cc[1] - b[1])+        if abs(d1[0] * d2[1] - d1[1] * d2[0]) < 1e-9:+            out.append(b); continue+        out.append((b[0] - d1[0] * c, b[1] - d1[1] * c))+        out.append((b[0] + d2[0] * c, b[1] + d2[1] * c))+    out.append(pts_xy[-1])+    return out+++def bus_route(k_from, k_to, y, first_layer="L5"):+    """Waypoint list (x, y, layer) for a bus line that starts west of corridor k_from on L5 and exits into+    gap (k_to - 1, k_to). Each corridor: L5 under its west half, a via in the R0-R3 gap, L3 over its east half;+    each gap passed through: back to L5."""+    nodes = []+    for k in range(k_from, k_to):+        xv = X_CORR_VIA(k)+        nodes.append((xv, y, "L5"))+        nodes.append((xv, y, "L3"))+        if k < k_to - 1:+            xg = CX[k] + G_CONT+            nodes.append((xg, y, "L3"))+            nodes.append((xg, y, "L5"))+    return nodes+++def refclk(k):+    """REFCLK k from its gold fingers to OCuLink port k (A12 p / A13 n)."""+    P = Pair(f"CLK{k}", f"REFCLKn{k}", f"REFCLKp{k}", "REFCLK")+    if k in (0, 1):+        pins = ("B14", "B15") if k == 0 else ("A14", "A15")+    else:+        pins = ("OCP_B11", "OCP_B12") if k == 2 else ("OCP_A11", "OCP_A12")+    lay0 = "L1" if pins[0].startswith(("B", "OCP_B")) else "L6"+    P.start = (finger(pins[0]), finger(pins[1]), lay0)+    xc_f = F + PX[pins[0]] + 0.3+    y0 = PAD_TOP - 0.25 - (0.6 - pitch(lay0)) / 2+    P.add(xc_f, y0, lay0)+    cx = CX[k]+    if k == 0:+        P.add(xc_f, 107.9, "L1"); P.add(19.1, 107.9 - (xc_f - 19.1), "L1"); P.add(19.1, 97.0, "L1")+        P.add(19.1, 97.0, "L3"); P.add(19.1, 96.3, "L3"); P.add(3.6, 96.3 - 15.5, "L3")+        for (x, y) in corner_path([(3.6, 80.8), (3.6, Y_REF_TURN), (cx - 0.5, Y_REF_TURN), (cx - 0.5, Y_REF_TURN - 0.8)], "L3")[1:]:+            P.add(x, y, "L3")+        lay = "L3"+    else:+        yb = Y_BUS[f"REFCLK{k}"]+        if k == 1:+            P.add(xc_f, 108.4, "L6"); P.add(17.6, 108.4 - (xc_f - 17.6), "L6"); P.add(17.6, 98.0, "L6")+            P.add(17.6, 98.0, "L5"); xs = 17.6+        elif k == 2:+            P.add(xc_f, 107.9, "L1"); P.add(12.6, 107.9 - (xc_f - 12.6), "L1"); P.add(12.6, 106.5, "L1")+            P.add(12.6, 106.5, "L5"); xs = 12.6+        else:+            P.add(xc_f, 107.9, "L6"); P.add(14.6, 107.9 - (14.6 - xc_f), "L6"); P.add(14.6, 106.5, "L6")+            P.add(14.6, 106.5, "L5"); xs = 14.6+        for (x, y) in corner_path([(xs, 106.0 if k > 1 else 97.6), (xs, yb), (X_CORR_VIA(0), yb)], "L5")[1:]:+            P.add(x, y, "L5")+        for nd in bus_route(0, k, yb)[1:]:+            P.add(*nd)+        g = CX[k - 1] + G_REF+        lay = "L3"+        pts = corner_path([(P.nodes[-1][0], yb), (g, yb), (g, Y_M2)], "L3")+        for (x, y) in pts[1:]:+            P.add(x, y, lay)+        P.add(g - S_EAST, Y_M2 - S_EAST, lay)+        for (x, y) in corner_path([(g - S_EAST, Y_M2 - S_EAST), (g - S_EAST, Y_REF_TURN), (cx - 0.5, Y_REF_TURN), (cx - 0.5, Y_REF_TURN - 0.8)], lay)[1:]:+            P.add(x, y, lay)+    # B-row gap B11/B14, strip, A12/A13+    xg, xa = cx - 0.5, cx - 1.0+    c = 0.25+    P.add(xg, Y_STRIP + c, lay); P.add(xg - c, Y_STRIP, lay); P.add(xa + c, Y_STRIP, lay); P.add(xa, Y_STRIP - c, lay)+    P.add(xa, Y_AROW + 0.42, lay)+    P.end = ((cx + 4.75 - 0.5 * 12, Y_AROW), (cx + 4.75 - 0.5 * 11, Y_AROW), lay, True)+    return build(P)+++class Line:+    """A single-ended route: nodes (x, y, layer); a layer change places one via."""+    def __init__(self, name, net, w=0.15):+        self.name, self.net, self.w = name, net, w+        self.nodes, self.tracks, self.vias = [], [], []++    def add(self, x, y, layer):+        self.nodes.append((round(x, 4), round(y, 4), layer)); return self++    def build(self):+        for a, b in zip(self.nodes, self.nodes[1:]):+            if a[2] != b[2]:+                assert abs(a[0] - b[0]) < 1e-6 and abs(a[1] - b[1]) < 1e-6+                self.vias.append((a[0], a[1], self.net, VIA_D, VIA_H))+            elif math.hypot(b[0] - a[0], b[1] - a[1]) > 1e-6:+                self.tracks.append((a[2], self.net, a[0], a[1], b[0], b[1], self.w))+        return self+++def perst_bus(k):+    """PORTk_PERST# from its bus start via (fed from U4 through R1x by the maze router) to the B12 pad of port k."""+    L = Line(f"PERST{k}", f"PORT{k}_PERST#")+    y = Y_BUS[f"PERST{k}"]+    cx = CX[k]+    L.add(BUS_START_X, y, "L1"); L.add(BUS_START_X, y, "L5")+    for nd in bus_route(0, k, y):+        L.add(*nd)+    g = CX[k - 1] + G_PERST+    L.add(g, y, "L3"); L.add(g, y, "L1")+    for (x, yy) in corner_path([(g, y), (g, Y_M2), (g - S_EAST, Y_M2 - S_EAST), (g - S_EAST, Y_PERST_TURN),+                                (cx - 0.25, Y_PERST_TURN), (cx - 0.25, Y_BROW)], "L1", c=0.3)[1:]:+        L.add(x, yy, "L1")+    return L.build()+++def all_routes():+    tune()+    pairs = all_lanes() + [refclk(k) for k in range(4)]+    lines = [perst_bus(k) for k in (1, 2, 3)]+    return pairs, lines+++if __name__ == "__main__":+    pairs = all_lanes()+    print(len(pairs), "pairs", sum(len(p.tracks) for p in pairs), "tracks", sum(len(p.vias) for p in pairs), "vias")
gen/ic_symbols.pyadded+66
@@ -0,0 +1,66 @@+#!/usr/bin/env python3+"""Adom-style IC symbols (adom-symbol create: functional pin groups, the 3D-chip outline baked in) for the+riser's ICs. Pin numbers and names from the manufacturer datasheets (TI SN74LV165A SCLS402, TI TPS3808 SBVS050,+TI SN74LVC125A SCAS290, TI TPD4E05U06 SLVSBO7, ROHM BR24H64xxx-5AC)."""+import json, os, subprocess+OUT = os.path.join(os.path.dirname(__file__), "..", "kicad", "lib", "adom-symbols")+P = lambda n, num, t, g, side=None: dict(name=n, number=str(num), type=t, group=g, **({"side": side} if side else {}))+ICS = {+ "SN74LV165APWR": dict(manufacturer="Texas Instruments", package="TSSOP-16", description="8-bit parallel-load shift register (OCP NIC 3.0 scan chain SR0)",+   datasheetUrl="https://www.ti.com/lit/ds/symlink/sn74lv165a.pdf", pins=[+   P("A", 11, "input", "PARALLEL IN", "left"), P("B", 12, "input", "PARALLEL IN", "left"), P("C", 13, "input", "PARALLEL IN", "left"),+   P("D", 14, "input", "PARALLEL IN", "left"), P("E", 3, "input", "PARALLEL IN", "left"), P("F", 4, "input", "PARALLEL IN", "left"),+   P("G", 5, "input", "PARALLEL IN", "left"), P("H", 6, "input", "PARALLEL IN", "left"),+   P("SER", 10, "input", "SERIAL", "right"), P("QH", 9, "output", "SERIAL", "right"), P("~{QH}", 7, "output", "SERIAL", "right"),+   P("SH/~{LD}", 1, "input", "CONTROL", "right"), P("CLK", 2, "input", "CONTROL", "right"), P("CLK_INH", 15, "input", "CONTROL", "right"),+   P("VCC", 16, "power_in", "POWER", "right"), P("GND", 8, "power_in", "POWER", "left")]),+ "BR24H64FJ-5ACE2": dict(manufacturer="ROHM Semiconductor", package="SOP-J8", description="64 Kbit I2C EEPROM, 2-byte addressing (OCP NIC 3.0 FRU)",+   datasheetUrl="https://fscdn.rohm.com/en/products/databook/datasheet/ic/memory/eeprom/br24h64xxx-5ac-e.pdf", pins=[+   P("A0", 1, "input", "ADDRESS", "left"), P("A1", 2, "input", "ADDRESS", "left"), P("A2", 3, "input", "ADDRESS", "left"),+   P("WP", 7, "input", "CONTROL", "left"), P("SDA", 5, "bidirectional", "I2C", "right"), P("SCL", 6, "input", "I2C", "right"),+   P("VCC", 8, "power_in", "POWER", "right"), P("GND", 4, "power_in", "POWER", "left")]),+ "TPS3808G33DBV": dict(manufacturer="Texas Instruments", package="SOT-23-6 (DBV)", description="Voltage supervisor 3.07 V with manual reset and programmable delay",+   datasheetUrl="https://www.ti.com/lit/ds/symlink/tps3808.pdf", pins=[+   P("SENSE", 5, "input", "MONITOR", "left"), P("~{MR}", 3, "input", "MONITOR", "left"), P("CT", 4, "passive", "DELAY", "left"),+   P("~{RESET}", 1, "open_collector", "OUTPUT", "right"), P("VDD", 6, "power_in", "POWER", "right"), P("GND", 2, "power_in", "POWER", "left")]),+ "SN74LVC125APWR": dict(manufacturer="Texas Instruments", package="TSSOP-14", description="Quad bus buffer with 3-state outputs, Ioff",+   datasheetUrl="https://www.ti.com/lit/ds/symlink/sn74lvc125a.pdf", pins=[+   P("1A", 2, "input", "INPUTS", "left"), P("2A", 5, "input", "INPUTS", "left"), P("3A", 9, "input", "INPUTS", "left"), P("4A", 12, "input", "INPUTS", "left"),+   P("~{1OE}", 1, "input", "ENABLES", "left"), P("~{2OE}", 4, "input", "ENABLES", "left"), P("~{3OE}", 10, "input", "ENABLES", "left"), P("~{4OE}", 13, "input", "ENABLES", "left"),+   P("1Y", 3, "tri_state", "OUTPUTS", "right"), P("2Y", 6, "tri_state", "OUTPUTS", "right"), P("3Y", 8, "tri_state", "OUTPUTS", "right"), P("4Y", 11, "tri_state", "OUTPUTS", "right"),+   P("VCC", 14, "power_in", "POWER", "right"), P("GND", 7, "power_in", "POWER", "left")]),+ "TPD4E05U06QDQARQ1": dict(manufacturer="Texas Instruments", package="USON-10 (DQA)", description="4-channel ESD protection, 0.5 pF",+   datasheetUrl="https://www.ti.com/lit/ds/symlink/tpd4e05u06.pdf", pins=[+   P("D1+", 1, "passive", "PROTECTED", "left"), P("D1-", 2, "passive", "PROTECTED", "left"), P("D2+", 4, "passive", "PROTECTED", "left"), P("D2-", 5, "passive", "PROTECTED", "left"),+   P("NC", 6, "no_connect", "NC", "right"), P("NC", 7, "no_connect", "NC", "right"), P("NC", 9, "no_connect", "NC", "right"), P("NC", 10, "no_connect", "NC", "right"),+   P("GND", 3, "power_in", "GROUND", "left"), P("GND", 8, "power_in", "GROUND", "left")]),+}+if __name__ == "__main__":+    for name, d in ICS.items():+        spec = dict(symbolName=name, pinCount=len(d["pins"]), referencePrefix="U", **d)+        jf = os.path.join(OUT, name + ".create.json")+        json.dump(spec, open(jf, "w"), indent=1)+        r = subprocess.run(["adom-symbol", "create", "--file", jf, "--out", os.path.join(OUT, name)], capture_output=True, text=True)+        tail = [l for l in (r.stdout + r.stderr).splitlines() if l.strip()][-3:]+        print(name, r.returncode, " | ".join(tail)[:300])+++def snap_pins(path, grid=2.54):+    """adom-symbol 1.2.2 spaces pin groups by non-grid gaps; snap every pin end onto the grid of the first pin+    (moves of at most half a grid step) so KiCad ERC sees on-grid connection points."""+    import re+    txt = open(path).read()+    m = re.search(r'\(pin \w+ \w+ \(at ([-\d.]+) ([-\d.]+) ', txt)+    y0 = float(m.group(2))+    def fix(mm):+        x, y = float(mm.group(1)), float(mm.group(2))+        yn = y0 + round((y - y0) / grid) * grid+        return f"(at {x:.2f} {yn:.2f} "+    txt = re.sub(r'(?<=\(pin )(\w+ \w+ )\(at ([-\d.]+) ([-\d.]+) ', lambda mm: mm.group(1) + fix(type("M", (), {"group": lambda s, i: mm.group(i + 1)})()), txt)+    open(path, "w").write(txt)+++if __name__ == "__main__":+    for name in ICS:+        snap_pins(os.path.join(OUT, name, name + ".kicad_sym"))+    print("pins snapped")
gen/layout.pyadded+124
@@ -0,0 +1,124 @@+#!/usr/bin/env python3+"""Board outline, keepouts and component placement for the riser (board frame: x 0..76, y 0 faceplate .. 115 fingers)."""+import json, math, os, sys+sys.path.insert(0, os.path.dirname(__file__))+import hs++W, L = 76.0, 115.0+H = 38.0+++def outline():+    """Closed list of (kind, ...) segments: ('line', x1,y1,x2,y2) or ('arc', sx,sy, mx,my, ex,ey).+    OCP NIC 3.0 v1.6.0 Fig. 8/9 + SFF-TA-1002 Fig. 5-39 (tongue, keys, lead-in chamfers)."""+    segs = []+    F = hs.F+    tongue = [(F - 31.480, F - 20.4 - 1.125), (F - 20.4 + 1.125, F - 0.925),+              (F + 0.925, F + 12.26 - 1.375), (F + 12.26 + 1.375, F + 31.995)]+    slots = [(F - 20.4, 2.25, 7.0), (F, 1.85, 7.0), (F + 12.26, 2.75, 7.0)]+    ch = 0.75     # 45 degree lead-in on every tongue corner+    # ears: x [0, 3.76] and [72.24, 76]; outer slots 2.50 wide, 6.10 deep; 30 degree chamfer 3.00 long+    ear_w, oslot_w, oslot_d = 3.7625, 2.5, 6.10+    c30 = 3.00 * math.tan(math.radians(30))+    pts = []++    def P(x, y):+        pts.append((x, y))++    # start at faceplate edge, top-left, go clockwise in board frame (x right, y down)+    P(0, 0); P(W, 0)+    # right long edge down to the latch notch: notch 3.50 wide, 2.75 deep, ends 30.00 from the card edge+    ny1, ny0 = L - 30.0 - 3.5, L - 30.0+    P(W, ny1); P(W - 2.75, ny1); P(W - 2.75, ny0); P(W, ny0)+    # right ear with 30 degree chamfer+    P(W, L - 3.00); P(W - c30, L); P(W - ear_w, L)+    # right outer slot (round bottom)+    segs_pts = list(pts)+    return segs_pts, tongue, slots, (ear_w, oslot_w, oslot_d, c30, ny1, ny0, ch)+++def outline_poly():+    """The outline as a simple polygon (arcs as polylines) for checks, built independently of outline_segments."""+    F = hs.F+    pts = []+    W_, L_ = W, L+    c30 = 3.00 * math.tan(math.radians(30))+    ear = 3.7625+    ow, od = 2.5, 6.10+    ch = 0.75+    tongue = [(F - 31.480, F - 20.4 - 1.125), (F - 20.4 + 1.125, F - 0.925),+              (F + 0.925, F + 12.26 - 1.375), (F + 12.26 + 1.375, F + 31.995)]+    keys = [(F - 20.4, 2.25, 7.0), (F, 1.85, 7.0), (F + 12.26, 2.75, 7.0)]+    ny1, ny0 = L_ - 33.5, L_ - 30.0++    def roundslot(xc, w, d, east_to_west=True, n=12):+        r = w / 2+        out = [(xc + r, L_), (xc + r, L_ - d + r)]+        for i in range(1, n):+            a = math.pi * i / n+            out.append((xc + r * math.cos(a), L_ - d + r - r * math.sin(a)))+        out += [(xc - r, L_ - d + r), (xc - r, L_)]+        return out++    pts += [(0, 0), (W_, 0), (W_, ny1), (W_ - 2.75, ny1), (W_ - 2.75, ny0), (W_, ny0), (W_, L_ - 3.00), (W_ - c30, L_),+            (W_ - ear, L_)]+    # right outer slot+    pts += roundslot(tongue[3][1] + ow / 2, ow, od)[1:-1]+    for i in range(3, -1, -1):+        a, b = tongue[i]+        pts += [(b, L_ - ch), (b - ch, L_), (a + ch, L_), (a, L_ - ch)]+        if i > 0:+            xc, w, d = keys[i - 1]+            pts += roundslot(xc, w, d)[1:-1]+    pts += roundslot(tongue[0][0] - ow / 2, ow, od)[1:-1]+    pts += [(ear, L_), (c30, L_), (0, L_ - 3.00), (0, ny0), (2.75, ny0), (2.75, ny1), (0, ny1)]+    return pts+++# ------------------------------------------------------------------------------------------ placement+def placement():+    """ref -> (x, y, rot_deg, side). rot in KiCad degrees (counter-clockwise on screen)."""+    P = {}+    P["J1"] = (hs.F, L, 0, "F")+    for k in range(4):+        P[f"J{2+k}"] = (hs.CX[k], 0.0, 0, "F")+    P["H1"] = (5.5, 115 - 109.76, 0, "F")+    P["H2"] = (70.5, 115 - 109.76, 0, "F")+    # west region (top side): scan chain, FRU, supervisor, PERST buffer+    P["U1"] = (8.8, 60.0, 0, "F")       # TSSOP-16+    P["C1"] = (13.3, 56.4, 90, "F")+    P["R3"] = (4.2, 55.4, 0, "F"); P["R4"] = (4.2, 56.75, 0, "F"); P["R5"] = (4.6, 64.0, 0, "F")+    P["R6"] = (13.3, 62.6, 90, "F"); P["R7"] = (4.6, 65.4, 0, "F")+    P["U2"] = (8.8, 72.0, 0, "F")       # SOIC-8+    P["C2"] = (13.4, 69.0, 90, "F"); P["R8"] = (4.6, 75.6, 0, "F"); P["JP1"] = (5.2, 78.2, 0, "F")+    P["U3"] = (8.8, 48.6, 0, "F")       # SOT-23-6+    P["C3"] = (12.4, 48.6, 90, "F"); P["R9"] = (5.2, 50.8, 0, "F")+    P["U4"] = (8.8, 39.0, 0, "F")       # TSSOP-14+    P["C4"] = (13.3, 35.6, 90, "F")+    for k in range(4):+        P[f"R{11+k}"] = (13.6, 38.2 + 1.3 * k, 0, "F")    # 33R series+    # power indication and bulk+    P["C5"] = (14.2, 92.0, 90, "F")+    P["D1"] = (5.0, 28.0, 90, "F"); P["R23"] = (5.0, 31.0, 90, "F")+    P["D2"] = (7.4, 28.0, 90, "F"); P["R24"] = (7.4, 31.0, 90, "F")+    P["TP1"] = (4.6, 87.0, 0, "F"); P["TP2"] = (4.6, 89.8, 0, "F"); P["TP3"] = (4.6, 92.6, 0, "F")+    P["TP4"] = (4.6, 95.4, 0, "F"); P["TP5"] = (4.6, 98.2, 0, "F")+    # 0.5 mm probe pads on their nets' routed top-layer traces (design/tp_spots.json, found by clearance search)+    _sp = json.load(open(os.path.join(os.path.dirname(__file__), "..", "design", "tp_spots.json")))+    for _ref, _net in (("TP6", "NIC_PWR_GOOD0"), ("TP7", "PORT0_PERST#"), ("TP8", "PORT1_PERST#"), ("TP9", "PORT2_PERST#"),+                       ("TP10", "PORT3_PERST#"), ("TP11", "SCAN0_CLK"), ("TP12", "SCAN0_DATA_OUT")):+        P[_ref] = (_sp[_net][1], _sp[_net][2], 0, "F")+    # PRSNT straps beside B70 (east)+    P["R1"] = (71.6, 101.6, 90, "F"); P["R2"] = (70.4, 101.6, 90, "F")+    # per-port ESD between the connector and the PERST# jog (pins 1..5 face the connector at rotation 270)+    for k in range(4):+        P[f"U{5+k}"] = (hs.CX[k] - 3.0, 9.6, 270, "F")+        P[f"R{15+k}"] = (15.8, 38.2 + 1.3 * k, 0, "F")      # PERST# pull-downs beside the 33 ohm series parts+    return P+++if __name__ == "__main__":+    pl = placement()+    import outline2+    json.dump({"placement": pl, "outline": outline2.polygon(), "edges": outline2.fillet()[0]}, open(os.path.join(os.path.dirname(__file__), "..", "design", "layout.json"), "w"), indent=1)+    print(len(pl), "placed;", len(outline_poly()), "outline points")
gen/live_plan.pyadded+117
@@ -0,0 +1,117 @@+#!/usr/bin/env python3+"""Split the finished routing into (a) the start board's copper and (b) what AI Flow lands live:+placement moves for every part that is not a fixed interface part, and a routing plan (one entry per+polyline, layer markers for vias) for every maze-routed net and GND fan-out."""+import json, os, math+from collections import defaultdict+ROOT = os.path.join(os.path.dirname(__file__), "..")+R = json.load(open(os.path.join(ROOT, "design", "routes.json")))+LS = json.load(open(os.path.join(ROOT, "design", "routes_ls.json")))+lay = json.load(open(os.path.join(ROOT, "design", "layout.json")))+KL = {"L1": "F.Cu", "L3": "In2.Cu", "L5": "In4.Cu", "L6": "B.Cu"}+FIXED = {"J1", "J2", "J3", "J4", "J5", "H1", "H2"}++# live copper: everything the maze produced (routes_ls minus the fixed copper it inherited) plus GND fan-outs+if "fixed_tracks" in LS:+    fixed_T = set(map(tuple, LS["fixed_tracks"])); fixed_V = set(map(tuple, LS["fixed_vias"]))+else:   # older routes_ls.json: rebuild the fixed copper exactly as route_all does before routing+    import sys; sys.path.insert(0, os.path.dirname(__file__))+    import route_all as RA+    fixed_T = set(map(tuple, RA.T)); fixed_V = set(map(tuple, RA.V))+# taken from the FINAL routes (routes.json): the finishing pass prunes stubs and may rip and re-route a few nets, so+# the maze's own output is stale for those; GND copper is live only where the maze or the fan-out made it+ls_T = {tuple(t) for t in LS["tracks"]} - fixed_T; ls_V = {tuple(v) for v in LS["vias"]} - fixed_V+fan_T = set(map(tuple, R["fanout_tracks"])); fan_V = set(map(tuple, R["fanout_vias"]))+live_T = [t for t in map(tuple, R["tracks"]) if t not in fixed_T and (t[1] != "GND" or t in ls_T or t in fan_T)]+live_V = [v for v in map(tuple, R["vias"]) if v not in fixed_V and (v[2] != "GND" or v in ls_V or v in fan_V)]+live_Ts, live_Vs = set(live_T), set(live_V)+start_T = [t for t in map(tuple, R["tracks"]) if t not in live_Ts]+start_V = [v for v in map(tuple, R["vias"]) if v not in live_Vs]+++def key(x, y):+    return (round(x, 3), round(y, 3))+++def chains(net, tracks, vias):+    """Decompose one net's live copper into polylines; a via joins layers at one point."""+    adj = defaultdict(list)    # (layer, pt) -> [(edge id, other node)]+    edges = []+    for (L, n, x1, y1, x2, y2, w) in tracks:+        a, b = (L, key(x1, y1)), (L, key(x2, y2))+        eid = len(edges); edges.append((a, b, w))+        adj[a].append((eid, b)); adj[b].append((eid, a))+    viapts = {key(v[0], v[1]) for v in vias}+    lone = []+    for vp in viapts:            # via: connect every layer node at that point+        nodes = [nd for nd in list(adj) if nd[1] == vp]+        if len(nodes) < 2:       # copper on one layer only (the other side is a pad or a pour): a via of its own+            lone.append((vp, nodes[0][0] if nodes else "L1"))+        for i in range(len(nodes) - 1):+            a, b = nodes[i], nodes[i + 1]+            eid = len(edges); edges.append((a, b, None))+            adj[a].append((eid, b)); adj[b].append((eid, a))+    used = set()+    out = []+    order = sorted(adj, key=lambda nd: len(adj[nd]) % 2 == 0)   # odd-degree nodes first+    for start in order:+        while any(e not in used for e, _ in adj[start]):+            path = [start]; cur = start; w = None+            while True:+                nxt = [(e, o) for e, o in adj[cur] if e not in used]+                if not nxt:+                    break+                e, o = nxt[0]+                used.add(e)+                if edges[e][2] is not None:+                    w = edges[e][2]+                path.append(o); cur = o+            out.append((path, w))+    entries = []+    for path, w in out:+        pts = []+        layer = path[0][0]+        for i, (L, (x, y)) in enumerate(path):+            if i == 0:+                pts.append([x, y])+            elif L != layer:+                pts.append({"x": x, "y": y, "layer": KL[L]})+                layer = L+            elif pts and isinstance(pts[-1], dict) and abs(pts[-1]["x"] - x) < 1e-6 and abs(pts[-1]["y"] - y) < 1e-6:+                continue+            else:+                pts.append([x, y])+        # a via at the start of a path: express as a layer marker on the first point+        entries.append({"net": net, "width": w or 0.15, "viaSize": 0.4, "viaDrill": 0.2, "layer": KL[path[0][0]], "points": pts})+    vsize = {key(v[0], v[1]): (v[3], v[4]) for v in vias}+    for (x, y), L in lone:+        other = "B.Cu" if KL[L] == "F.Cu" else "F.Cu"+        d, h = vsize[(x, y)]+        entries.append({"net": net, "width": 0.15, "viaSize": d, "viaDrill": h, "layer": KL[L],+                        "points": [[x, y], {"x": x, "y": y, "layer": other}]})+    return entries+++bynet_T, bynet_V = defaultdict(list), defaultdict(list)+for t in live_T:+    bynet_T[t[1]].append(t)+for v in live_V:+    bynet_V[v[2]].append(v)+plan = []+for net in sorted(set(bynet_T) | set(bynet_V)):+    plan += chains(net, bynet_T[net], bynet_V[net])+moves = {}+stage = {}+k = 0+for ref, (x, y, rot, side) in lay["placement"].items():+    if ref in FIXED:+        continue+    moves[ref] = {"x": x, "y": y, "rotation": rot}+    stage[ref] = (84.0 + 9.0 * (k % 4), 8.0 + 6.0 * (k // 4), rot, side)+    k += 1+json.dump({"nets": plan}, open(os.path.join(ROOT, "aiflow", "route-plan.json"), "w"), indent=0)+json.dump(moves, open(os.path.join(ROOT, "aiflow", "moves.json"), "w"), indent=1)+json.dump({"tracks": start_T, "vias": start_V, "zones": R["zones"], "texts": R.get("texts", []), "stage": stage},+          open(os.path.join(ROOT, "design", "routes_start.json"), "w"))+print(f"live: {len(plan)} plan entries ({len(live_T)} tracks, {len(live_V)} vias); moves {len(moves)} parts;"+      f" start board copper {len(start_T)} tracks, {len(start_V)} vias")
gen/maze.pyadded+204
@@ -0,0 +1,204 @@+#!/usr/bin/env python3+"""Multi-layer A* maze router for the riser's low-speed nets, on a 0.1 mm grid.++Copper of every net is rasterised per layer into a label grid; a net is routed through cells whose distance to+other nets' copper is at least (clearance + width/2) (scipy distance transform per net). Vias need a free disk+on every copper layer. Routed copper is rasterised back before the next net."""+import heapq, json, math, os, sys+import numpy as np+from scipy import ndimage+from shapely.geometry import Polygon, LineString, Point, box+from shapely import affinity++G = 0.1+NX, NY = 760, 1150+LAYERS = ["L1", "L3", "L5", "L6"]          # routing/obstacle layers (L2/L4 are solid GND planes)+LI = {l: i for i, l in enumerate(LAYERS)}+VIA_D, VIA_H = 0.40, 0.20+++class Board:+    def __init__(self):+        self.lab = np.full((4, NY, NX), -1, np.int32)     # net id of copper in the cell, -1 none+        self.block = np.zeros((4, NY, NX), bool)          # static keepouts per layer+        self.nets = {}+        self.netname = []+        self.hs = np.zeros((4, NY, NX), bool)             # high-speed copper (gets extra clearance)+        yy, xx = np.mgrid[0:NY, 0:NX]+        self.X = xx * G+        self.Y = yy * G++    def nid(self, name):+        if name not in self.nets:+            self.nets[name] = len(self.netname)+            self.netname.append(name)+        return self.nets[name]++    def paint_geom(self, layer_idx, geom, net, hs=False):+        """Rasterise a shapely geometry (cells whose centre lies inside), dilated by 0.06 mm so the painted cells+        cover the true copper edge (a cell centre can sit up to ~0.07 mm inside it)."""+        geom = geom.buffer(0.06)+        x0, y0, x1, y1 = geom.bounds+        i0, i1 = max(0, int(x0 / G) - 1), min(NX - 1, int(x1 / G) + 2)+        j0, j1 = max(0, int(y0 / G) - 1), min(NY - 1, int(y1 / G) + 2)+        if i1 < i0 or j1 < j0:+            return+        from shapely import contains_xy+        sub = contains_xy(geom, self.X[j0:j1 + 1, i0:i1 + 1], self.Y[j0:j1 + 1, i0:i1 + 1])+        # thin geometry may contain no cell centre: paint the nearest cells of its centreline+        if not sub.any():+            c = geom.centroid+            sub = np.zeros_like(sub)+            jj, ii = int(round(c.y / G)) - j0, int(round(c.x / G)) - i0+            if 0 <= jj < sub.shape[0] and 0 <= ii < sub.shape[1]:+                sub[jj, ii] = True+        for li in ([layer_idx] if isinstance(layer_idx, int) else layer_idx):+            region = self.lab[li, j0:j1 + 1, i0:i1 + 1]+            region[sub] = net+            if hs:+                self.hs[li, j0:j1 + 1, i0:i1 + 1][sub] = True++    def track(self, layer, net, x1, y1, x2, y2, w, hs=False):+        g = LineString([(x1, y1), (x2, y2)]).buffer(w / 2, cap_style=1) if (x1, y1) != (x2, y2) else Point(x1, y1).buffer(w / 2)+        self.paint_geom(LI[layer], g, self.nid(net), hs)++    def via(self, x, y, net, d, hs=False):+        self.paint_geom([0, 1, 2, 3], Point(x, y).buffer(d / 2), self.nid(net), hs)+++def free_mask(B, net, w, clr, clr_hs, li):+    """cells where a track of width w for `net` may have its centreline on layer li."""+    lab = B.lab[li]+    other = (lab >= 0) & (lab != net)+    d = ndimage.distance_transform_edt(~other) * G+    ok = d >= clr + w / 2 - 1e-9+    if clr_hs > clr:+        oh = other & B.hs[li]+        dh = ndimage.distance_transform_edt(~oh) * G+        ok &= dh >= clr_hs + w / 2 - 1e-9+    ok &= ~B.block[li]+    return ok+++def route_net(B, net, terminals, w=0.15, clr=0.15, clr_hs=0.3, allow=("L1", "L3", "L5"), via_cost=12.0,+              layer_cost=None, l6_region=None, max_expand=6_000_000):+    """terminals: list of sets of (li, j, i) cells (each a pad). Returns list of paths (lists of (li,j,i))."""+    nid = B.nid(net)+    layer_cost = layer_cost or {"L1": 1.0, "L3": 1.15, "L5": 1.15, "L6": 3.0}+    ok = np.zeros((4, NY, NX), bool)+    for l in allow:+        ok[LI[l]] = free_mask(B, nid, w, clr, clr_hs, LI[l])+    if "L6" in allow and l6_region is not None:+        ok[LI["L6"]] &= l6_region+    # via allowed where a via disk is clear on every layer (incl. L6, never in keepouts)+    vok = np.ones((NY, NX), bool)+    for li in range(4):+        lab = B.lab[li]+        other = (lab >= 0) & (lab != nid)+        d = ndimage.distance_transform_edt(~other) * G+        vok &= d >= clr + VIA_D / 2 - 1e-9+        oh = other & B.hs[li]+        dh = ndimage.distance_transform_edt(~oh) * G+        vok &= dh >= clr_hs + VIA_D / 2 - 1e-9+        vok &= ~B.block_via[li] if hasattr(B, "block_via") else True+    # terminals' own cells are always enterable on their layers+    for t in terminals:+        for (li, j, i) in t:+            ok[li, j, i] = True+    tree = set(terminals[0])+    paths = []+    remaining = [set(t) for t in terminals[1:]]+    allowed_li = [LI[l] for l in allow]+    lc = np.array([layer_cost.get(l, 1.0) for l in LAYERS])+    while remaining:+        goal = set().union(*remaining)+        gj = np.array([c[1] for c in goal]); gi = np.array([c[2] for c in goal])+        gcj, gci = gj.mean(), gi.mean()+        rad = max(np.hypot(gj - gcj, gi - gci).max(), 0)++        def h(j, i):+            dj, di = abs(j - gcj), abs(i - gci)+            return max(0.0, (max(dj, di) + 0.414 * min(dj, di)) - rad)++        start = list(tree)+        dist = {}+        prev = {}+        heap = []+        for s in start:+            dist[s] = 0.0+            heapq.heappush(heap, (h(s[1], s[2]), 0.0, s))+        found = None+        n = 0+        while heap:+            f, g, c = heapq.heappop(heap)+            if g > dist.get(c, 1e18) + 1e-9:+                continue+            if c in goal:+                found = c; break+            n += 1+            if n > max_expand:+                break+            li, j, i = c+            for dj, di, cost in ((0, 1, 1), (0, -1, 1), (1, 0, 1), (-1, 0, 1), (1, 1, 1.4142), (1, -1, 1.4142), (-1, 1, 1.4142), (-1, -1, 1.4142)):+                jj, ii = j + dj, i + di+                if 0 <= jj < NY and 0 <= ii < NX and ok[li, jj, ii]:+                    if dj and di and not (ok[li, j, ii] and ok[li, jj, i]):+                        continue+                    nc = (li, jj, ii)+                    ng = g + cost * lc[li]+                    if ng < dist.get(nc, 1e18):+                        dist[nc] = ng; prev[nc] = c+                        heapq.heappush(heap, (ng + h(jj, ii), ng, nc))+            if vok[j, i]:+                for l2 in allowed_li:+                    if l2 != li and ok[l2, j, i]:+                        nc = (l2, j, i)+                        ng = g + via_cost+                        if ng < dist.get(nc, 1e18):+                            dist[nc] = ng; prev[nc] = c+                            heapq.heappush(heap, (ng + h(j, i), ng, nc))+        if found is None:+            return None, f"no path ({n} expanded)"+        path = [found]+        while path[-1] in prev:+            path.append(prev[path[-1]])+        path.reverse()+        paths.append(path)+        tree |= set(path)+        for r in remaining:+            if found in r:+                tree |= r+                remaining.remove(r)+                break+    return paths, "ok"+++def path_to_copper(path, net, w):+    """Cells -> merged straight tracks and vias."""+    tracks, vias = [], []+    seg = [path[0]]++    def flush(seg):+        if len(seg) < 2:+            return+        pts = [(c[2] * G, c[1] * G) for c in seg]+        # merge collinear+        out = [pts[0]]+        for k in range(1, len(pts) - 1):+            a, b, c = out[-1], pts[k], pts[k + 1]+            if abs((b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0])) < 1e-9:+                continue+            out.append(b)+        out.append(pts[-1])+        for a, b in zip(out, out[1:]):+            tracks.append((LAYERS[seg[0][0]], net, round(a[0], 4), round(a[1], 4), round(b[0], 4), round(b[1], 4), w))++    for c in path[1:]:+        if c[0] != seg[-1][0]:+            flush(seg)+            vias.append((round(c[2] * G, 4), round(c[1] * G, 4), net, VIA_D, VIA_H))+            seg = [c]+        else:+            seg.append(c)+    flush(seg)+    return tracks, vias
gen/nc_nets.pyadded+14
@@ -0,0 +1,14 @@+#!/usr/bin/env python3+"""From KiCad's exported schematic netlist, the names KiCad gives unconnected pins ('unconnected-(J1A-SMRST0#-PadA9)'),+so the board's unused pads carry the same net and schematic parity is exact."""+import re, json, os, sys+ROOT = os.path.join(os.path.dirname(__file__), "..")+t = re.sub(r"\s+", " ", open(os.path.join(ROOT, "kicad", "sch.net")).read())+out = {}+for b in t.split("(net (code")[1:]:+    name = re.search(r'\(name "([^"]*)"\)', b).group(1)+    if name.startswith("unconnected-"):+        for r, p in re.findall(r'\(node \(ref "([^"]+)"\) \(pin "([^"]+)"\)', b):+            out[f"{r}.{p}"] = name+json.dump(out, open(os.path.join(ROOT, "design", "nc_nets.json"), "w"), indent=0)+print(len(out), "unconnected pins named")
gen/outline2.pyadded+102
@@ -0,0 +1,102 @@+#!/usr/bin/env python3+"""OCP NIC 3.0 SFF outline as lines and arcs: every corner filleted (slot bottoms are exact semicircles,+latch notch R0.50 outer / R0.25 inner per OCP Fig. 9, 0.2-0.3 mm on the chamfer vertices)."""+import math+import hs++W, L = 76.0, 115.0+++def vertices():+    F = hs.F+    c30 = 3.00 * math.tan(math.radians(30))+    ear, ow, od, ch = 3.7625, 2.5, 6.10, 0.75+    tongue = [(F - 31.480, F - 20.4 - 1.125), (F - 20.4 + 1.125, F - 0.925),+              (F + 0.925, F + 12.26 - 1.375), (F + 12.26 + 1.375, F + 31.995)]+    keys = [(F - 20.4, 2.25, 7.0), (F, 1.85, 7.0), (F + 12.26, 2.75, 7.0)]+    ny1, ny0 = L - 33.5, L - 30.0+    V = []++    def slot(xc, w, d):+        r = w / 2+        V.extend([(xc + r, L - d, r), (xc - r, L - d, r)])++    V += [(0, 0, 0.5), (W, 0, 0.5), (W, ny1, 0.5), (W - 2.75, ny1, 0.25), (W - 2.75, ny0, 0.25), (W, ny0, 0.5),+          (W, L - 3.00, 0.3), (W - c30, L, 0.3), (W - ear, L, 0.25)]+    slot(tongue[3][1] + ow / 2, ow, od)+    for i in range(3, -1, -1):+        a, b = tongue[i]+        V += [(b, L - ch, 0.2), (b - ch, L, 0.2), (a + ch, L, 0.2), (a, L - ch, 0.2)]+        if i > 0:+            slot(*keys[i - 1])+    slot(tongue[0][0] - ow / 2, ow, od)+    V += [(ear, L, 0.25), (c30, L, 0.3), (0, L - 3.00, 0.3), (0, ny0, 0.5), (2.75, ny0, 0.25), (2.75, ny1, 0.25), (0, ny1, 0.5)]+    return V+++def fillet():+    V = vertices()+    n = len(V)+    prims = []+    pts_after = []   # (start_of_next_line) per vertex+    arcs = []+    for i in range(n):+        p0 = V[i - 1]; p1 = V[i]; p2 = V[(i + 1) % n]+        d1 = (p1[0] - p0[0], p1[1] - p0[1]); l1 = math.hypot(*d1); d1 = (d1[0] / l1, d1[1] / l1)+        d2 = (p2[0] - p1[0], p2[1] - p1[1]); l2 = math.hypot(*d2); d2 = (d2[0] / l2, d2[1] / l2)+        cosang = max(-1, min(1, d1[0] * d2[0] + d1[1] * d2[1]))+        turn = math.acos(cosang)+        r = p1[2]+        t = r * math.tan(turn / 2)+        assert t <= l1 / 2 + 1e-3 and t <= l2 / 2 + 1e-3, (i, p1, t, l1, l2)+        a = (p1[0] - d1[0] * t, p1[1] - d1[1] * t)+        b = (p1[0] + d2[0] * t, p1[1] + d2[1] * t)+        # centre: from a, perpendicular toward the inside of the turn+        cross = d1[0] * d2[1] - d1[1] * d2[0]+        nrm = (-d1[1], d1[0]) if cross > 0 else (d1[1], -d1[0])+        c = (a[0] + nrm[0] * r, a[1] + nrm[1] * r)+        bis = ((a[0] + b[0]) / 2 - c[0], (a[1] + b[1]) / 2 - c[1]); bl = math.hypot(*bis)+        m = (c[0] + bis[0] / bl * r, c[1] + bis[1] / bl * r)+        arcs.append((a, m, b, c, r))+    for i in range(n):+        a_prev = arcs[i - 1][2]+        a_cur = arcs[i][0]+        s, m, e, c, r = arcs[i]+        if math.hypot(a_cur[0] - a_prev[0], a_cur[1] - a_prev[1]) > 1e-3:+            prims.append(("line", a_prev[0], a_prev[1], a_cur[0], a_cur[1]))+        else:+            s = a_prev          # tangent arcs meet: start exactly where the previous arc ended+        prims.append(("arc", s[0], s[1], m[0], m[1], e[0], e[1]))+    return prims, arcs+++def polygon(seg_per_arc=8):+    prims, arcs = fillet()+    pts = []+    for pr in prims:+        if pr[0] == "line":+            pts.append((pr[1], pr[2]))+        else:+            sx, sy, mx, my, ex, ey = pr[1:]+            # sample the arc through its three points+            import numpy as np+            ax, ay, bx, by, cx_, cy_ = sx, sy, mx, my, ex, ey+            d = 2 * (ax * (by - cy_) + bx * (cy_ - ay) + cx_ * (ay - by))+            ux = ((ax * ax + ay * ay) * (by - cy_) + (bx * bx + by * by) * (cy_ - ay) + (cx_ * cx_ + cy_ * cy_) * (ay - by)) / d+            uy = ((ax * ax + ay * ay) * (cx_ - bx) + (bx * bx + by * by) * (ax - cx_) + (cx_ * cx_ + cy_ * cy_) * (bx - ax)) / d+            r = math.hypot(ax - ux, ay - uy)+            a0 = math.atan2(ay - uy, ax - ux); a1 = math.atan2(by - uy, bx - ux); a2 = math.atan2(cy_ - uy, cx_ - ux)+            # go from a0 through a1 to a2+            def norm(t):+                return (t + 2 * math.pi) % (2 * math.pi)+            ccw = norm(a1 - a0) < norm(a2 - a0)+            span = norm(a2 - a0) if ccw else -norm(a0 - a2)+            for k in range(seg_per_arc):+                t = a0 + span * k / seg_per_arc+                pts.append((ux + r * math.cos(t), uy + r * math.sin(t)))+    return pts+++if __name__ == "__main__":+    prims, _ = fillet()+    print(len(prims), "primitives;", sum(1 for p in prims if p[0] == "arc"), "arcs;", len(polygon()), "polygon points")
gen/route_all.pyadded+275
@@ -0,0 +1,275 @@+#!/usr/bin/env python3+"""Assemble every route: high-speed (hs.py), fixed extras, maze-routed low-speed nets, GND fan-out, stitching,+zones and keepouts -> design/routes.json for build_board.py final."""+import json, math, os, sys, time+import numpy as np+from shapely.geometry import Polygon, Point, LineString, box+from shapely.ops import unary_union+from shapely import contains_xy+sys.path.insert(0, os.path.dirname(__file__))+import hs, maze+from maze import G, NX, NY, LI++ROOT = os.path.join(os.path.dirname(__file__), "..")+pads = json.load(open(os.path.join(ROOT, "design", "pads.json")))+lay = json.load(open(os.path.join(ROOT, "design", "layout.json")))+nl = json.load(open(os.path.join(ROOT, "design", "netlist.json")))+OUTLINE = Polygon(lay["outline"])+LAYMAP = {"F.Cu": "L1", "In2.Cu": "L3", "In4.Cu": "L5", "B.Cu": "L6"}++T, V = [], []           # all tracks and vias for routes.json+B = maze.Board()++# ------------------------------------------------------------------ fixed high-speed copper+pairs, lines = hs.all_routes()+for p in pairs:+    for t in p.tracks:+        T.append(t); B.track(*t, hs=True)+    for v in p.vias:+        V.append(v); B.via(v[0], v[1], v[2], v[3], hs=True)+for l in lines:+    for t in l.tracks:+        T.append(t); B.track(*t)+    for v in l.vias:+        V.append(v); B.via(v[0], v[1], v[2], v[3])++# ------------------------------------------------------------------ pads+PAD = {}+for p in pads:+    net = p["net"] or f"NC:{p['ref']}.{p['num']}"+    PAD[(p["ref"], p["num"], round(p["x"], 3), round(p["y"], 3))] = p+    geom = unary_union([Polygon(o) for o in p["poly"] if len(o) >= 3]) if p["poly"] else Point(p["x"], p["y"]).buffer(p["w"] / 2)+    p["geom"] = geom+    lis = [LI[LAYMAP[l]] for l in p["layers"] if l in LAYMAP]+    if len(p["layers"]) >= 6 or p["drill"][0] > 0:+        lis = [0, 1, 2, 3]+        # a plated hole occupies every layer with at least its drill+        dg = Point(p["x"], p["y"]).buffer(max(p["drill"]) / 2 + 0.05)+        p["geom"] = geom.union(dg)+    B.paint_geom(lis, p["geom"], B.nid(net))+    p["lis"] = lis++# ------------------------------------------------------------------ fixed extras+def add_track(t):+    T.append(t); B.track(*t)++def add_via(v, hsflag=False):+    V.append(v); B.via(v[0], v[1], v[2], v[3], hs=hsflag)++pin_net = {}+for n, ps in nl["nets"].items():+    for q in ps:+        pin_net[q] = n+# GND gold fingers: short stub off the inner end so the outer-layer GND pour picks them up+for p in pads:+    if p["ref"] == "J1" and p["net"] == "GND":+        layer = "L1" if "F.Cu" in p["layers"] else "L6"+        add_track((layer, "GND", p["x"], hs.PAD_TOP, p["x"], hs.PAD_TOP - 0.55, 0.3))+cols = {}+for p in pads:+    if p["ref"] == "J1":+        cols.setdefault(round(p["x"], 3), {})["F" if "F.Cu" in p["layers"] else "B"] = p["net"]+for x, sides in sorted(cols.items()):+    if sides.get("F") == "GND" and sides.get("B") == "GND":+        add_via((x, hs.PAD_TOP - 0.62, "GND", 0.4, 0.2))+# +12V fingers B1..B6: one bar+b1 = hs.finger("B1"); b6 = hs.finger("B6")+add_track(("L1", "+12V_EDGE", b1[0], b1[1], b1[0], 111.6, 0.3))+for n in range(2, 7):+    x, y = hs.finger(f"B{n}")+    add_track(("L1", "+12V_EDGE", x, y, x, 111.6, 0.3))+add_track(("L1", "+12V_EDGE", b1[0], 111.6, b6[0], 111.6, 0.3))+# OCuLink GND pads: via-in-pad (filled and capped)+for p in pads:+    if p["ref"] in ("J2", "J3", "J4", "J5") and p["net"] == "GND" and p["num"] != "SH":+        add_via((p["x"], p["y"], "GND", hs.VIP_D, hs.VIP_H), True)++# ------------------------------------------------------------------ finger escape reservations+# every used signal/power finger keeps a 2.4 mm column above it on its own layer, so no other net's via lands there+HS_NETS = set()+for _p in pairs:+    HS_NETS |= {_p.nnet, _p.pnet}+for p in pads:+    if p["ref"] != "J1" or not p["net"] or p["net"] in ("GND", "+12V_EDGE") or p["net"] in HS_NETS:+        continue+    li = LI["L1"] if "F.Cu" in p["layers"] else LI["L6"]+    w = 0.2+    B.paint_geom(li, box(p["x"] - w / 2, hs.PAD_TOP - 2.4, p["x"] + w / 2, hs.PAD_TOP), B.nid(p["net"]))++# ------------------------------------------------------------------ static keepouts+yy, xx = B.Y, B.X+inside = contains_xy(OUTLINE.buffer(-0.42), xx, yy)+for li in range(4):+    B.block[li] |= ~inside+# long-edge strips: no copper on outer layers within 2.75 mm, inner layers within 1.0 mm (OCP Fig. 8/10)+strip_outer = (xx < 2.75) | (xx > 76 - 2.75)+strip_inner = (xx < 1.0) | (xx > 75.0)+B.block[LI["L1"]] |= strip_outer; B.block[LI["L6"]] |= strip_outer+B.block[LI["L3"]] |= strip_inner; B.block[LI["L5"]] |= strip_inner+# gold-finger field: nothing else on the outer layers past the finger tops; inner copper clear of the bevel+B.block[LI["L1"]] |= yy > 112.05+B.block[LI["L6"]] |= yy > 112.05+B.block[LI["L3"]] |= yy > 113.6+B.block[LI["L5"]] |= yy > 113.6+B.block_via = np.zeros_like(B.block)+for li in range(4):+    B.block_via[li] = B.block[li] | (yy > 111.6)+++def terminals(net):+    out = []+    for p in pads:+        if p["net"] != net:+            continue+        cells = set()+        x0, y0, x1, y1 = p["geom"].bounds+        i0, i1, j0, j1 = int(x0 / G), int(x1 / G) + 1, int(y0 / G), int(y1 / G) + 1+        sub = contains_xy(p["geom"].buffer(-0.02), xx[j0:j1 + 1, i0:i1 + 1], yy[j0:j1 + 1, i0:i1 + 1])+        js, iss = np.nonzero(sub)+        for li in p["lis"]:+            for j, i in zip(js + j0, iss + i0):+                cells.add((li, int(j), int(i)))+        if not cells:+            cells = {(li, int(round(p["y"] / G)), int(round(p["x"] / G))) for li in p["lis"]}+        out.append(cells)+    # existing copper of the net (fixed lines) is one more terminal+    nid = B.nid(net)+    ex = set()+    for (lay_, n, x1, y1, x2, y2, w) in T:+        if n == net:+            L = max(1, int(math.hypot(x2 - x1, y2 - y1) / G))+            for k in range(L + 1):+                x = x1 + (x2 - x1) * k / L; y = y1 + (y2 - y1) * k / L+                ex.add((LI[lay_], int(round(y / G)), int(round(x / G))))+    if ex:+        out.insert(0, ex)+    return out+++L6_REGION = (yy > 101.0) & (xx < 30.0)+ROUTE = [+    # bottom-side (A) finger escapes first: each needs a via site above its own finger+    ("PERST1#", 0.15, ("L1", "L3", "L5", "L6")), ("PRSNTA0#", 0.15, ("L1", "L3", "L6")),+    ("PRSNTB2#", 0.15, ("L1", "L3", "L6")), ("SMCLK0", 0.15, ("L1", "L3", "L5", "L6")),+    ("SMDAT0", 0.15, ("L1", "L3", "L5", "L6")), ("PERST2#", 0.15, ("L1", "L3", "L5", "L6")),+    ("PERST3#", 0.15, ("L1", "L3", "L5", "L6")), ("WAKE0#", 0.15, ("L1", "L3", "L5", "L6")),+    ("SLOT_ID1", 0.15, ("L1", "L3", "L5", "L6")),+    # top side and power+    ("+3V3_EDGE", 0.2, ("L1", "L3", "L5")), ("+12V_EDGE", 0.2, ("L1", "L3", "L5")),+    ("PRSNTB3#", 0.15, ("L1", "L3")), ("PERST0#", 0.15, ("L1", "L3", "L5")),+    ("NIC_PWR_GOOD0", 0.15, ("L1", "L3", "L5")), ("SCAN0_LD#", 0.15, ("L1", "L3", "L5")),+    ("SCAN0_DATA_IN", 0.15, ("L1", "L3", "L5")), ("SCAN0_DATA_OUT", 0.15, ("L1", "L3", "L5")),+    ("SCAN0_CLK", 0.15, ("L1", "L3", "L5")), ("SLOT_ID0", 0.15, ("L1", "L3", "L5")),+    ("AUX_PWR_EN0", 0.15, ("L1", "L3", "L5")),+    ("PERST0_BUF", 0.15, ("L1", "L3", "L5")), ("PERST1_BUF", 0.15, ("L1", "L3", "L5")),+    ("PERST2_BUF", 0.15, ("L1", "L3", "L5")), ("PERST3_BUF", 0.15, ("L1", "L3", "L5")),+    ("PORT0_PERST#", 0.15, ("L1", "L3", "L5")), ("PORT1_PERST#", 0.15, ("L1", "L3", "L5")),+    ("PORT2_PERST#", 0.15, ("L1", "L3", "L5")), ("PORT3_PERST#", 0.15, ("L1", "L3", "L5")),+    ("PORT0_CPRSNT#", 0.15, ("L1", "L3", "L5")), ("PORT1_CPRSNT#", 0.15, ("L1", "L3", "L5")),+    ("PORT2_CPRSNT#", 0.15, ("L1", "L3", "L5")), ("PORT3_CPRSNT#", 0.15, ("L1", "L3", "L5")),+    ("FRU_WP", 0.15, ("L1", "L3", "L5")), ("SR0_SER", 0.15, ("L1", "L3", "L5")),+    ("LED3V3_A", 0.15, ("L1",)), ("LED12V_A", 0.15, ("L1",)),+]++CLR, CLR_HS = 0.14, 0.28+++def paint_net(net, tracks, vias):+    for t in tracks:+        B.track(*t)+    for v in vias:+        B.via(v[0], v[1], v[2], v[3])+++def route_list(order, routed, attempt_log):+    fails = []+    for (net, w, allow) in order:+        terms = terminals(net)+        if len(terms) < 2:+            continue+        paths, msg = maze.route_net(B, net, terms, w=w, clr=CLR, clr_hs=CLR_HS, allow=allow, l6_region=L6_REGION)+        if paths is None:+            fails.append(net); attempt_log.append(f"FAIL {net}: {msg}"); print(f"FAIL {net}: {msg}", flush=True); continue+        tr, vi = [], []+        for pth in paths:+            a_, b_ = maze.path_to_copper(pth, net, w)+            tr += a_; vi += b_+        routed[net] = (tr, vi)+        paint_net(net, tr, vi)+        print(f"{net}: {len(paths)} branch(es), {len(tr)} tracks, {len(vi)} vias", flush=True)+    return fails+++def rebuild(routed):+    """Fresh raster with fixed copper + pads + reservations + everything routed so far."""+    global B+    lab0, hs0, blk, blkv = BASE+    B.lab = lab0.copy(); B.hs = hs0.copy()+    for net, (tr, vi) in routed.items():+        paint_net(net, tr, vi)+++if __name__ == "__main__":+    t0 = time.time()+    FIXED_T, FIXED_V = list(T), list(V)+    BASE = (B.lab.copy(), B.hs.copy(), B.block.copy(), B.block_via.copy())+    W = {n: (w, a) for (n, w, a) in ROUTE}+    routed = {}+    log = []+    fails = route_list(ROUTE, routed, log)+    priority = []                      # every net that ever failed, routed first from then on+    for rnd in range(10):+        if not fails:+            break+        for n in fails:+            if n not in priority:+                priority.append(n)+        print(f"--- rip-up round {rnd+1}: {fails}  priority {priority}", flush=True)+        rip = set()+        for net in priority:+            for (li, j, i) in set().union(*terminals(net)):+                r = int(1.2 / G)+                for lj in range(4):+                    blk = B.lab[lj, max(0, j - r):j + r + 1, max(0, i - r):i + r + 1]+                    for nid in np.unique(blk):+                        if nid >= 0 and B.netname[nid] in routed:+                            rip.add(B.netname[nid])+        rip |= set(priority)+        for n in rip:+            routed.pop(n, None)+        rebuild(routed)+        # the nets that just failed get first pick, then the older failures, then the ripped-up rest+        first = list(fails) + [n for n in priority if n not in fails]+        order = [(n, *W[n]) for n in first] + [(n, *W[n]) for n in [x[0] for x in ROUTE] if n in rip and n not in priority]+        fails = route_list(order, routed, log)+    # ESD grounds: each ESD's GND pins to the GND pads of its connector+    for k in range(4):+        ref, conn = f"U{5+k}", f"J{2+k}"+        esd_cells, conn_cells = [], set()+        for p in pads:+            if p["net"] != "GND":+                continue+            if p["ref"] == ref or (p["ref"] == conn and p["num"] in ("B11", "B14", "B17", "B20")):+                x0, y0, x1, y1 = p["geom"].bounds+                i0, i1, j0, j1 = int(x0 / G), int(x1 / G) + 1, int(y0 / G), int(y1 / G) + 1+                sub = contains_xy(p["geom"].buffer(-0.02), xx[j0:j1 + 1, i0:i1 + 1], yy[j0:j1 + 1, i0:i1 + 1])+                js, iss = np.nonzero(sub)+                cells = {(LI["L1"], int(j + j0), int(i + i0)) for j, i in zip(js, iss)}+                if p["ref"] == ref:+                    esd_cells.append(cells)+                else:+                    conn_cells |= cells+        terms = [conn_cells] + esd_cells+        paths, msg = maze.route_net(B, "GND", terms, w=0.2, clr=CLR, clr_hs=CLR_HS, allow=("L1",))+        if paths is None:+            print(f"FAIL ESD GND {ref}: {msg}"); fails.append(f"GND:{ref}"); continue+        tr, vi = [], []+        for pth in paths:+            a_, b_ = maze.path_to_copper(pth, "GND", 0.2); tr += a_; vi += b_+        routed[f"GND:{ref}"] = (tr, vi); paint_net("GND", tr, vi)+        print(f"GND {ref}: {len(tr)} tracks")+    for net, (tr, vi) in routed.items():+        T.extend(tr); V.extend(vi)+    json.dump({"tracks": T, "vias": V, "fails": fails, "fixed_tracks": FIXED_T, "fixed_vias": FIXED_V},+              open(os.path.join(ROOT, "design", "routes_ls.json"), "w"))+    print("fails:", fails, f"[{time.time()-t0:.0f}s]")
gen/run_pipeline.shadded+16
@@ -0,0 +1,16 @@+#!/usr/bin/env bash+# Full offline build: schematic -> netlist check -> base board -> routing -> finishing -> final board -> ERC/DRC+set -e+cd "$(dirname "$0")"+K=/mnt/c/Users/john/AppData/Local/Programs/KiCad/10.0/bin+PY=${PY:-python3}+python3 design.py && python3 footprints.py >/dev/null && python3 layout.py && python3 gen_schematic.py+(cd ../kicad && $K/kicad-cli.exe sch export netlist --output sch.net ocp-oculink-riser.kicad_sch >/dev/null)+python3 sch_compare.py && python3 nc_nets.py+$K/python.exe "$(wslpath -w build_board.py)" base+$PY -u route_all.py+$PY -u finish_routes.py+$K/python.exe "$(wslpath -w build_board.py)" final+cd ../kicad+$K/kicad-cli.exe sch erc --format json --severity-all --output erc.json ocp-oculink-riser.kicad_sch+$K/kicad-cli.exe pcb drc --format json --severity-all --schematic-parity --output drc.json ocp-oculink-riser.kicad_pcb
gen/sch_compare.pyadded+21
@@ -0,0 +1,21 @@+#!/usr/bin/env python3+"""Compare a KiCad-exported schematic netlist (kicadsexpr) with design/netlist.json, pin by pin."""+import re, json, sys, os+ROOT = os.path.join(os.path.dirname(__file__), "..")+t = open(sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "kicad", "sch.net")).read()+t = re.sub(r"\s+", " ", t)+sch = {}+for b in t.split("(net (code")[1:]:+    name = re.search(r'\(name "([^"]*)"\)', b).group(1).lstrip("/")+    nodes = re.findall(r'\(node \(ref "([^"]+)"\) \(pin "([^"]+)"\)', b)+    if name.startswith(("unconnected", "Net-(")):+        continue+    sch[name] = sorted(f"{r}.{p}" for r, p in nodes)+want = {k: sorted(v) for k, v in json.load(open(os.path.join(ROOT, "design", "netlist.json")))["nets"].items()}+ps = {p: n for n, l in sch.items() for p in l}+pw = {p: n for n, l in want.items() for p in l}+diff = sorted((p, pw.get(p), ps.get(p)) for p in set(ps) | set(pw) if pw.get(p) != ps.get(p))+print(f"schematic nets {len(sch)}, netlist.json nets {len(want)}, connected pins {len(pw)}, mismatches {len(diff)}")+for d in diff[:30]:+    print("  ", d)+sys.exit(1 if diff else 0)
gen/silk.pyadded+299
@@ -0,0 +1,299 @@+"""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)
gen/silk_audit_input.pyadded+42
@@ -0,0 +1,42 @@+"""Whole-board silkscreen audit input (KiCad python.exe): every fitted body (courtyard box), every pad with its mask+opening, every via and plated/non-plated hole as obstacles on both silk layers; every silk text and visible field as+a mark. Writes design/silk_audit_input.json for `adom-aiflow silkscreen-audit`."""+import json, os, sys+import pcbnew+TO = pcbnew.ToMM+b = pcbnew.LoadBoard(sys.argv[1])+def bx(e): return [round(TO(e.GetLeft()), 3), round(TO(e.GetTop()), 3), round(TO(e.GetRight()), 3), round(TO(e.GetBottom()), 3)]+obs, inv, marks = [], [], []+for fp in b.GetFootprints():+    ref = fp.GetReference()+    for side, lay, sl in (("F", pcbnew.F_CrtYd, "F.SilkS"), ("B", pcbnew.B_CrtYd, "B.SilkS")):+        cy = fp.GetCourtyard(lay)+        if cy.OutlineCount() and ref != "J1":+            obs.append(dict(id=f"{ref}:body", kind="body", layer=sl, box=bx(cy.BBox())))+            inv.append(dict(id=f"{ref}:body", kind="body", layers=[sl]))+    for p in fp.Pads():+        pid = f"{ref}.{p.GetNumber() or 'np'}@{TO(p.GetPosition().x):.2f},{TO(p.GetPosition().y):.2f}"+        e = p.GetBoundingBox()+        layers = []+        if p.IsOnLayer(pcbnew.F_Mask) or p.HasHole(): layers.append("F.SilkS")+        if p.IsOnLayer(pcbnew.B_Mask) or p.HasHole(): layers.append("B.SilkS")+        for sl in layers:+            obs.append(dict(id=pid, kind="pad", layer=sl, box=bx(e)))+        if layers: inv.append(dict(id=pid, kind="pad", layers=layers))+    for f in (fp.Reference(), fp.Value()):+        if f.IsVisible() and f.GetLayer() in (pcbnew.F_SilkS, pcbnew.B_SilkS):+            marks.append(dict(id=f"{ref}:{'ref' if f is fp.Reference() or f.GetText()==ref else 'value'}:{f.GetText()}", layer=b.GetLayerName(f.GetLayer()), box=bx(f.GetBoundingBox())))+k = 0+for t in b.GetTracks():+    if t.GetClass() == "PCB_VIA":+        k += 1+        x, y, r = TO(t.GetPosition().x), TO(t.GetPosition().y), TO(t.GetDrillValue()) / 2+        vid = f"via{k}@{x:.2f},{y:.2f}"+        for sl in ("F.SilkS", "B.SilkS"):+            obs.append(dict(id=vid, kind="hole", layer=sl, box=[round(x - r, 3), round(y - r, 3), round(x + r, 3), round(y + r, 3)]))+        inv.append(dict(id=vid, kind="hole", layers=["F.SilkS", "B.SilkS"]))+for d in b.GetDrawings():+    if d.GetClass() == "PCB_TEXT" and d.GetLayer() in (pcbnew.F_SilkS, pcbnew.B_SilkS):+        marks.append(dict(id=f"text:{d.GetText()}", layer=b.GetLayerName(d.GetLayer()), box=bx(d.GetBoundingBox())))+json.dump(dict(obstacles=obs, inventory=inv, marks=marks), open(sys.argv[2], "w"))+print(f"obstacles {len(obs)}, inventory {len(inv)}, marks {len(marks)}")
gen/silk_fields_apply.pyadded+22
@@ -0,0 +1,22 @@+"""Apply the reference/value field placements of design/silk_plan.json to a SAVED board file in place (KiCad 10.0.5's+IPC server cannot update footprint fields, so this is a disclosed saved-file edit; the editor is closed while it runs+and reopened after). Run with KiCad's python.exe: silk_fields_apply.py <board.kicad_pcb>"""+import json, os, sys+import pcbnew+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")+MM = pcbnew.FromMM+b = pcbnew.LoadBoard(sys.argv[1])+plan = json.load(open(os.path.join(ROOT, "design", "silk_plan.json")))+n = 0+for fl in plan["fields"]:+    fp = b.FindFootprintByReference(fl["ref"])+    f = fp.Reference() if fl["field"] == "Reference" else fp.Value()+    assert f.m_Uuid.AsString() == fl["id"], (fl["ref"], fl["field"], "field identity changed")+    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"])))+    n += 1+pcbnew.SaveBoard(sys.argv[1], b)+print("fields applied:", n)
gen/silk_live.pyadded+92
@@ -0,0 +1,92 @@+#!/usr/bin/env python3+"""Land design/silk_plan.json on the live board through the KiCad bridge, in small groups (one undo step each), so+the silkscreen step's clip shows the labels going on. Writes design/silk_events.json (time, group, ids, reason)."""+import json, os, subprocess, sys, time++ROOT = os.path.join(os.path.dirname(__file__), "..")+BOARD = "C:/Users/john/Documents/adom-aiflow/ocp-oculink-riser/ocp-oculink-riser.kicad_pcb"+THREAD = "OCP OCuLink riser"+plan = json.load(open(os.path.join(ROOT, "design", "silk_plan.json")))+events = []+++def bridge(verb, args):+    args = dict(args, reason=args.get("reason", "silkscreen step: land the reviewed label plan"))+    out = subprocess.run(["adom-bridge", "--ai-thread", THREAD, verb, json.dumps(args)], capture_output=True, text=True, timeout=300)+    try:+        d = json.loads(out.stdout)+    except Exception:+        raise SystemExit(f"{verb}: unreadable reply {out.stdout[:300]} {out.stderr[:300]}")+    r = d.get("result", d)+    if isinstance(r, dict) and "output" in r and isinstance(r["output"], str):+        try:+            r = json.loads(r["output"]).get("data", r)+        except Exception:+            pass+    return d, r+++def revision():+    _, r = bridge("kicad_placement_state", {"filePath": BOARD})+    return r.get("revision")+++def group_of(item):+    if item.get("field"):+        ref = item["ref"]+        y = item["y"]+        return f"refs {'faceplate' if y < 20 else 'logic' if y < 75 else 'edge'}"+    t = item["text"]+    if item["layer"] == "B.SilkS":+        return "bottom face"+    if t.startswith(("OCP_B", "B")) and item["y"] > 105:+        return "finger pins"+    if t.startswith("J") or "PORT" in t:+        return "ports"+    return "labels"+++ORDER = ["finger pins", "ports", "refs faceplate", "refs logic", "labels", "refs edge", "bottom face"]+# KiCad 10.0.5's IPC server cannot update footprint fields: references/values go in by a disclosed saved-file edit+# (gen/silk.py apply on the saved board); this script lands the standalone texts live. ONLY limits a rerun.+ONLY = sys.argv[1].split(",") if len(sys.argv) > 1 else ORDER+groups = {g: [] for g in ORDER}+for f in plan["fields"]:+    groups[group_of(f)].append(("field", f))+for t in plan["texts"]:+    groups[group_of(t)].append(("text", t))++rev = revision()+for g in ORDER:+    if g not in ONLY:+        continue+    items = [it for it in groups[g] if it[0] == "text"]+    # small batches: a ref/value pair or a few labels at a time, in reading order+    items.sort(key=lambda it: (round(it[1]["y"] / 6), it[1]["x"]))+    for k in range(0, len(items), 4):+        chunk = items[k:k + 4]+        fields = [dict(id=it["id"], x=it["x"], y=it["y"], rotation=it["rotation"], layer=it["layer"], height=it["height"],+                       width=it["height"], stroke=it["stroke"], visible=True) for kind, it in chunk if kind == "field"]+        texts = [dict(text=it["text"], layer=it["layer"], x=it["x"], y=it["y"], height=it["height"], width=it["height"],+                      stroke=it["stroke"], rotation=it["rotation"], align=it["align"]) for kind, it in chunk if kind == "text"]+        t0 = time.time()+        if fields:+            d, r = bridge("kicad_silk_fields_batch", {"filePath": BOARD, "expectedRevision": rev, "update": fields})+            if d.get("status") != "ok" and not r.get("revision"):+                print("fields batch refused:", json.dumps(d)[:600]); sys.exit(1)+            rev = r.get("revision") or revision()+        if texts:+            d, r = bridge("kicad_silk_text_batch", {"filePath": BOARD, "expectedRevision": rev, "create": texts})+            if d.get("status") != "ok" and not r.get("revision"):+                print("text batch refused:", json.dumps(d)[:600]); sys.exit(1)+            rev = r.get("revision") or revision()+        events.append(dict(t=round(t0, 1), group=g, op="place",+                           items=[(it.get("ref") or "") + ("." + it["field"] if kind == "field" else "") + (it.get("text", "") if kind == "text" else "")+                                  for kind, it in chunk],+                           reason=[it.get("why") or f"{it['ref']} {it['field'].lower()} beside its part" for kind, it in chunk]))+        print(f"{g}: {', '.join(events[-1]['items'])}", flush=True)+        time.sleep(1.2)+_ev = os.path.join(ROOT, "design", "silk_events.json")+prev = json.load(open(_ev)) if os.path.exists(_ev) and len(sys.argv) > 1 else []+json.dump(prev + events, open(_ev, "w"), indent=1)+print("landed", sum(len(e["items"]) for e in events), "labels in", len(events), "steps; revision", rev)
gen/stepbox.pyadded+156
@@ -0,0 +1,156 @@+#!/usr/bin/env python3+"""Write an AP214 STEP file made of coloured axis-aligned boxes (plain Python, no CAD kernel).++Used for the AI-generated simplified OCuLink receptacle model (Amphenol G14A42111C12HR): dimensions from Amphenol+drawing G14A421X1BXXXHR rev D. Model frame = KiCad footprint frame turned into KiCad's 3D frame:+x right, y = -(pcb y), z up from the board top."""+import datetime+++class Step:+    def __init__(self, name):+        self.name = name+        self.lines = []+        self.n = 0+        self.solids = []      # (brep_id, rgb)++    def add(self, s):+        self.n += 1+        self.lines.append(f"#{self.n}={s};")+        return self.n++    def pt(self, x, y, z):+        return self.add(f"CARTESIAN_POINT('',({x:.4f},{y:.4f},{z:.4f}))")++    def dirn(self, x, y, z):+        return self.add(f"DIRECTION('',({x:.1f},{y:.1f},{z:.1f}))")++    def box(self, x0, y0, z0, x1, y1, z1, rgb):+        P = {}+        for i, x in enumerate((x0, x1)):+            for j, y in enumerate((y0, y1)):+                for k, z in enumerate((z0, z1)):+                    P[(i, j, k)] = self.add(f"VERTEX_POINT('',#{self.pt(x, y, z)})")+        coords = {(i, j, k): ((x0, x1)[i], (y0, y1)[j], (z0, z1)[k]) for i in (0, 1) for j in (0, 1) for k in (0, 1)}+        edges = {}++        def edge(a, b):+            key = tuple(sorted((a, b)))+            if key in edges:+                return edges[key], key[0] == a+            pa, pb = coords[key[0]], coords[key[1]]+            d = tuple(pb[t] - pa[t] for t in range(3))+            L = max(abs(c) for c in d)+            dv = self.dirn(*(c / L for c in d))+            vec = self.add(f"VECTOR('',#{dv},{L:.4f})")+            ln = self.add(f"LINE('',#{self.pt(*pa)},#{vec})")+            e = self.add(f"EDGE_CURVE('',#{P[key[0]]},#{P[key[1]]},#{ln},.T.)")+            edges[key] = e+            return e, key[0] == a++        faces = []+        # each face: 4 corners in counter-clockwise order seen from outside, outward normal+        F = [+            ([(0, 0, 0), (0, 1, 0), (1, 1, 0), (1, 0, 0)], (0, 0, -1), (x0, y0, z0)),+            ([(0, 0, 1), (1, 0, 1), (1, 1, 1), (0, 1, 1)], (0, 0, 1), (x0, y0, z1)),+            ([(0, 0, 0), (1, 0, 0), (1, 0, 1), (0, 0, 1)], (0, -1, 0), (x0, y0, z0)),+            ([(0, 1, 0), (0, 1, 1), (1, 1, 1), (1, 1, 0)], (0, 1, 0), (x0, y1, z0)),+            ([(0, 0, 0), (0, 0, 1), (0, 1, 1), (0, 1, 0)], (-1, 0, 0), (x0, y0, z0)),+            ([(1, 0, 0), (1, 1, 0), (1, 1, 1), (1, 0, 1)], (1, 0, 0), (x1, y0, z0)),+        ]+        for corners, nrm, org in F:+            oes = []+            for a, b in zip(corners, corners[1:] + corners[:1]):+                e, same = edge(a, b)+                oes.append(self.add(f"ORIENTED_EDGE('',*,*,#{e},{'.T.' if same else '.F.'})"))+            loop = self.add(f"EDGE_LOOP('',({','.join('#%d' % o for o in oes)}))")+            bound = self.add(f"FACE_OUTER_BOUND('',#{loop},.T.)")+            ref = (1, 0, 0) if abs(nrm[0]) < 0.5 else (0, 1, 0)+            ax = self.add(f"AXIS2_PLACEMENT_3D('',#{self.pt(*org)},#{self.dirn(*nrm)},#{self.dirn(*ref)})")+            pl = self.add(f"PLANE('',#{ax})")+            faces.append(self.add(f"ADVANCED_FACE('',(#{bound}),#{pl},.T.)"))+        shell = self.add(f"CLOSED_SHELL('',({','.join('#%d' % f for f in faces)}))")+        brep = self.add(f"MANIFOLD_SOLID_BREP('{self.name}_{len(self.solids)}',#{shell})")+        self.solids.append((brep, rgb))+        return brep++    def write(self, path, description):+        head = self.lines+        self.lines = []+        n0 = self.n+        ctx_ids = []+        # units and context+        lu = self.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))")+        au = self.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))")+        su = self.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())")+        unc = self.add(f"UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#{lu},'distance_accuracy_value','confusion accuracy')")+        ctx = self.add(f"(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{lu},#{au},#{su}))REPRESENTATION_CONTEXT('Context #1','3D Context with UNIT and UNCERTAINTY'))")+        org = self.add(f"AXIS2_PLACEMENT_3D('',#{self.pt(0, 0, 0)},#{self.dirn(0, 0, 1)},#{self.dirn(1, 0, 0)})")+        appctx = self.add("APPLICATION_CONTEXT('core data for automotive mechanical design processes')")+        self.add(f"APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,#{appctx})")+        pctx = self.add(f"PRODUCT_CONTEXT('',#{appctx},'mechanical')")+        prod = self.add(f"PRODUCT('{self.name}','{self.name}','',(#{pctx}))")+        pdfw = self.add(f"PRODUCT_DEFINITION_FORMATION('','',#{prod})")+        pdctx = self.add(f"PRODUCT_DEFINITION_CONTEXT('part definition',#{appctx},'design')")+        pd = self.add(f"PRODUCT_DEFINITION('design','',#{pdfw},#{pdctx})")+        pds = self.add(f"PRODUCT_DEFINITION_SHAPE('','',#{pd})")+        brep_list = ",".join("#%d" % b for b, _ in self.solids)+        rep = self.add(f"ADVANCED_BREP_SHAPE_REPRESENTATION('{self.name}',(#{org},{brep_list}),#{ctx})")+        self.add(f"SHAPE_DEFINITION_REPRESENTATION(#{pds},#{rep})")+        # colours+        styled = []+        for b, (r, g, bl) in self.solids:+            col = self.add(f"COLOUR_RGB('',{r:.3f},{g:.3f},{bl:.3f})")+            fas = self.add(f"FILL_AREA_STYLE_COLOUR('',#{col})")+            fa = self.add(f"FILL_AREA_STYLE('',(#{fas}))")+            ssfa = self.add(f"SURFACE_STYLE_FILL_AREA(#{fa})")+            sss = self.add(f"SURFACE_SIDE_STYLE('',(#{ssfa}))")+            ssu = self.add(f"SURFACE_STYLE_USAGE(.BOTH.,#{sss})")+            psa = self.add(f"PRESENTATION_STYLE_ASSIGNMENT((#{ssu}))")+            styled.append(self.add(f"STYLED_ITEM('color',(#{psa}),#{b})"))+        self.add(f"MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',({','.join('#%d' % s for s in styled)}),#{ctx})")+        body = head + self.lines+        now = datetime.datetime(2026, 10, 12, 22, 0, 0).strftime("%Y-%m-%dT%H:%M:%S")+        with open(path, "w") as fh:+            fh.write("ISO-10303-21;\nHEADER;\n")+            fh.write(f"FILE_DESCRIPTION(('{description}'),'2;1');\n")+            fh.write(f"FILE_NAME('{self.name}.step','{now}',('Adom AI (Claude), generated'),('Adom'),'stepbox.py','stepbox.py','');\n")+            fh.write("FILE_SCHEMA(('AUTOMOTIVE_DESIGN { 1 0 10303 214 1 1 1 1 }'));\nENDSEC;\nDATA;\n")+            fh.write("\n".join(body))+            fh.write("\nENDSEC;\nEND-ISO-10303-21;\n")+++def oculink_model(path):+    """Simplified G14A42111C12HR: dimensions from Amphenol drawing G14A421X1BXXXHR rev D."""+    s = Step("Amphenol_G14A42111C12HR")+    BLACK, NICKEL, GOLD = (0.08, 0.08, 0.09), (0.72, 0.72, 0.70), (0.86, 0.69, 0.25)+    W2 = 13.15 / 2+    # KiCad 3D frame: y3d = -y_pcb. Body from pcb y -1.98 (mating face) to 7.02 -> y3d +1.98 .. -7.02; height 3.90+    s.box(-W2 + 0.25, -7.02, 0.0, W2 - 0.25, 1.70, 3.65, BLACK)            # insulator housing+    s.box(-W2, -6.90, 3.65, W2, 1.98, 3.90, NICKEL)                         # shell top+    s.box(-W2, -6.90, 0.25, -W2 + 0.25, 1.98, 3.65, NICKEL)                 # shell sides+    s.box(W2 - 0.25, -6.90, 0.25, W2, 1.98, 3.65, NICKEL)+    s.box(-W2, 1.73, 0.25, W2, 1.98, 0.75, NICKEL)                          # shell front lip (bottom)+    # shell legs (option C, 2.90 long) at x +/-6.58, pcb y 7.05 and 2.26: 0.5 x 1.1 blades through the board+    for sx in (-6.58, 6.58):+        for py in (7.05, 2.26):+            s.box(sx - 0.25, -py - 0.55, -2.90 + 0.25, sx + 0.25, -py + 0.55, 0.25, NICKEL)+    # SMT contact tails: B row at pcb y 7.37, A row at 5.18 (0.2 x 0.7 x 0.15), pitch 0.5+    for n in range(21):+        xb = 5.25 - 0.5 * n+        xa = 4.75 - 0.5 * n+        s.box(xb - 0.1, -7.37 - 0.35, 0.0, xb + 0.1, -7.37 + 0.35, 0.15, GOLD)+        s.box(xa - 0.1, -5.18 - 0.35, 0.0, xa + 0.1, -5.18 + 0.35, 0.15, GOLD)+    # plastic pegs dia 1.00 (as 0.8 squares) at pcb (+5.41, 4.46) and (-5.94, 4.46)+    for (px, py) in ((5.41, 4.46), (-5.94, 4.46)):+        s.box(px - 0.4, -py - 0.4, -1.2, px + 0.4, -py + 0.4, 0.0, BLACK)+    s.write(path, "AI-generated simplified model of Amphenol G14A42111C12HR OCuLink 4X R/A receptacle; dimensions from Amphenol drawing G14A421X1BXXXHR rev D; not a manufacturer model")+    return path+++if __name__ == "__main__":+    import os, sys+    out = os.path.join(os.path.dirname(__file__), "..", "kicad", "3d", "Amphenol_G14A42111C12HR.step")+    os.makedirs(os.path.dirname(out), exist_ok=True)+    oculink_model(out)+    print("wrote", out, os.path.getsize(out), "bytes")