#!/usr/bin/env python3 """Create the compact placement draft from a fresh KiCad XML netlist. Requires system Python + pcbnew. Refuses to overwrite an existing PCB unless --overwrite is explicit. This is a draft generator, not a manufacturing tool. Does not modify the schematic, footprint libraries or project settings. SaveBoard(..., True) deliberately preserves the user's existing project rules. """ import argparse from pathlib import Path import re import subprocess import xml.etree.ElementTree as ET import pcbnew as p ROOT = Path(__file__).resolve().parent PCB = ROOT / "serial-carrier.kicad_pcb" LIBS = Path("/usr/share/kicad/footprints") ORIGIN = (100, 100) OUTLINE = [(36,0), (82,0), (82,80), (0,80), (0,19), (36,19)] PLACEMENT = { "U1": ("Carrier:HW678_2x22_Provisional", 8, 23), "U2": ("Carrier:MAX3243_Reference_Provisional", 43, 26.670), "DS1": ("Carrier:OLED_26mm_I2C_Provisional", 43.97, 36), "R1": ("Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal", 42.16, 32.6685), "R2": ("Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal", 53.16, 32.6685), "R3": ("Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal", 64.16, 32.6685), "SW1": ("Button_Switch_THT:SW_TH_Tactile_Omron_B3F-100x", 41.72, 68), "SW2": ("Button_Switch_THT:SW_TH_Tactile_Omron_B3F-100x", 53.72, 68), "SW3": ("Button_Switch_THT:SW_TH_Tactile_Omron_B3F-100x", 65.72, 68), } MOUNTS = [(3.5,22.5), (3.5,75), (78,4), (78,76)] # Deliberate allocation, NOT a measured antenna envelope or an RF guarantee. RF_RESERVE = [(10,19), (31,19), (31,32), (10,32)] def mm(v): return p.FromMM(v) def xy(x,y): return p.VECTOR2I(mm(x+ORIGIN[0]), mm(y+ORIGIN[1])) def text(board, value, x, y, layer=p.F_SilkS, size=1): item = p.PCB_TEXT(board) item.SetText(value) item.SetPosition(xy(x,y)) item.SetLayer(layer) item.SetTextSize(p.VECTOR2I(mm(size),mm(size))) item.SetTextThickness(mm(0.15)) board.Add(item) return item def polygon(zone, points): poly=zone.Outline() poly.NewOutline() for x,y in points: pt=xy(x,y) poly.Append(pt.x,pt.y) def load(lib_id): lib,name=lib_id.split(":",1) folder=ROOT / "Carrier.pretty" if lib=="Carrier" else LIBS / (lib+".pretty") fp=p.FootprintLoad(str(folder),name) if fp is None: raise RuntimeError("Cannot load "+lib_id) fp.SetFPIDAsString(lib_id) return fp def quiet_footprint(fp): # Retain all mechanical geometry, but replace sprawling research annotation # text with concise board annotations. Libraries are never modified. for graphic in list(fp.GraphicalItems()): if isinstance(graphic,p.PCB_TEXT): fp.Remove(graphic) fp.Value().SetVisible(False) fp.Reference().SetTextSize(p.VECTOR2I(mm(1),mm(1))) fp.Reference().SetTextThickness(mm(0.15)) fp.Reference().SetLayer(p.F_SilkS) def copy_schematic_fields(fp, component): for field in component.findall("fields/field"): name=field.get("name") if name in ("Reference", "Value", "Footprint"): continue fp.SetField(name, field.text or "") fp.GetField(name).SetVisible(False) def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument("--overwrite",action="store_true") args=parser.parse_args() if PCB.exists() and not args.overwrite: parser.error("PCB exists; preserve manual edits or explicitly use --overwrite") if ROOT.joinpath("~serial-carrier.kicad_pcb.lck").exists(): parser.error("PCB is open in KiCad; close it before regenerating") netfile=ROOT / "validation/pcb-source-netlist.xml" subprocess.run(["kicad-cli","sch","export","netlist","--format","kicadxml","-o",str(netfile),str(ROOT/"serial-carrier.kicad_sch")],check=True) source=ET.parse(netfile).getroot() components={c.get("ref"):c for c in source.findall("components/comp")} assert set(components)==set(PLACEMENT), "Schematic component set changed; review layout" schematic=ROOT.joinpath("serial-carrier.kicad_sch").read_text() root_uuid=re.search(r'\(uuid "?([a-f0-9-]{36})"?\)',schematic).group(1) board=p.BOARD() board.SetCopperLayerCount(2) settings=board.GetDesignSettings() settings.SetBoardThickness(mm(1.6)) settings.m_MinClearance=mm(0.25) settings.m_CopperEdgeClearance=mm(0.5) settings.m_HoleClearance=mm(0.25) settings.m_TrackMinWidth=mm(0.25) settings.m_ViasMinSize=mm(0.7) # Proposed in-memory draft rules only; never overwrite the user's project settings. title=board.GetTitleBlock() title.SetTitle("Serial Swiss Army Knife - 82x80 mm carrier") title.SetRevision("A1") title.SetComment(0,"") pad_nets={} for source_net in source.findall("nets/net"): net=p.NETINFO_ITEM(board,source_net.get("name")) board.Add(net) for node in source_net.findall("node"): pad_nets[node.get("ref"),node.get("pin")]=(net,node) for ref,(lib_id,x,y) in PLACEMENT.items(): comp=components[ref] assert comp.findtext("footprint")==lib_id,(ref,"Schematic footprint changed") fp=load(lib_id) fp.SetReference(ref) fp.SetValue(comp.findtext("value")) path=p.KIID_PATH() path.push_back(p.KIID(root_uuid)) path.push_back(p.KIID(comp.findtext("tstamps"))) fp.SetPath(path) fp.SetSheetfile("serial-carrier.kicad_sch") fp.SetSheetname("serial-carrier") fp.SetPosition(xy(x,y)) board.Add(fp) quiet_footprint(fp) copy_schematic_fields(fp,comp) if ref=="U1": fp.Reference().SetPosition(xy(20.7,44)) elif ref=="U2": fp.Reference().SetPosition(xy(56.97,14.670)) elif ref=="DS1": fp.Reference().SetPosition(xy(56.97,49)) elif ref.startswith("SW"): fp.Reference().SetPosition(xy(x+3.25,y-2)) else: fp.Reference().SetLayer(p.F_Fab) fp.Reference().SetPosition(xy(x+3.81,y-1.6)) for pad in fp.Pads(): key=ref,pad.GetNumber() if pad.GetNumber(): net,node=pad_nets[key] pad.SetNet(net) pad.SetPinFunction(node.get("pinfunction", "")) pad.SetPinType(node.get("pintype", "passive")) for i,(x,y) in enumerate(MOUNTS,1): fp=load("MountingHole:MountingHole_3.2mm_M3") fp.SetReference("H"+str(i)) fp.SetValue("M3 mounting proposal / 3.2mm") fp.SetBoardOnly(True) fp.SetExcludedFromBOM(True) fp.SetExcludedFromPosFiles(True) fp.SetPosition(xy(x,y)) board.Add(fp) quiet_footprint(fp) fp.Reference().SetVisible(False) for a,b in zip(OUTLINE,OUTLINE[1:]+OUTLINE[:1]): edge=p.PCB_SHAPE(board) edge.SetShape(p.SHAPE_T_SEGMENT) edge.SetStart(xy(*a)); edge.SetEnd(xy(*b)) edge.SetLayer(p.Edge_Cuts); edge.SetWidth(mm(0.05)) board.Add(edge) reserve=p.ZONE(board) reserve.SetIsRuleArea(True) layers=p.LSET(); layers.AddLayer(p.F_Cu); layers.AddLayer(p.B_Cu) reserve.SetLayerSet(layers) reserve.SetZoneName("PROVISIONAL RF RESERVE - verify actual antenna clearance") reserve.SetDoNotAllowTracks(True); reserve.SetDoNotAllowVias(True) reserve.SetDoNotAllowZoneFills(True); reserve.SetDoNotAllowPads(True) reserve.SetDoNotAllowFootprints(False) # the module itself spans this region polygon(reserve,RF_RESERVE) board.Add(reserve) text(board,"RF RESERVE",20.5,26,p.Dwgs_User,1.2) text(board,"VERIFY ANTENNA",20.5,29,p.Dwgs_User,1) text(board,"ESP32 HW678",20.7,48) text(board,"DE-9 OUT / NOMINAL MALE",56.97,-1.830,p.Dwgs_User,1) text(board,"MAX3243 REF",56.97,17.670) text(board,"26mm OLED",56,52) for label,x in zip(("BACK","SELECT","NEXT"),(46.25,58.25,70.25)): text(board,label,x,75, size=0.9) text(board,"82 x 80 mm / 2 copper layers",41,-5,p.Dwgs_User,1.5) from update_presentation import CAPTION text(board,CAPTION,41,-2.5,p.Dwgs_User,0.9) board.BuildConnectivity() assert p.SaveBoard(str(PCB),board,True) # Separate process avoids repeated-load SWIG lifetime issues in pcbnew. import sys subprocess.run([sys.executable, "-B", str(ROOT/"update_presentation.py")], check=True) print("Created",PCB,"with 9 circuit footprints, 4 proposed M3 mounts, 2 copper layers.") print("Placement draft only. User-confirmed fit and pin map; RF/electrical/fabrication qualification not established.") if __name__=="__main__": main()