board
OCuLink Riser for the Supermicro AIOM Slot
Public Unreviewedby John Lauer
Passive OCP NIC 3.0 SFF card that breaks a Supermicro AIOM slot's PCIe x16 into four OCuLink 4i ports (4 x4, Gen4): 6-layer, 85 ohm, length-matched, DRC-clean KiCad project with Gerbers, BOM and CPL.
← Commit history
README: component table per the wiki format, open-source section, no duplicate hero; generators at gen/
26 files changed
+3780−19
README.md+32−18design/README.md+1−1gen/build_board.py+207gen/check.py+76gen/delay.py+33gen/design.py+208gen/export_fab.py+105gen/finish_routes.py+329gen/footprints.py+216gen/fru.py+103gen/gen_schematic.py+361gen/hs.py+559gen/ic_symbols.py+66gen/layout.py+124gen/live_plan.py+117gen/maze.py+204gen/nc_nets.py+14gen/outline2.py+102gen/route_all.py+275gen/run_pipeline.sh+16gen/sch_compare.py+21gen/silk.py+299gen/silk_audit_input.py+42gen/silk_fields_apply.py+22gen/silk_live.py+92gen/stepbox.py+156README.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")