#!/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 or footprint libraries. KiCad SaveBoard also saves PCB design settings into the project; close the project before regeneration. """ import argparse import json 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 = [(0,0), (82,0), (82,80), (36,80), (36,61), (0,61)] PLACEMENT = { "U1": ("Carrier:HW678_2x22_Provisional", 8, 4), "U2": ("Carrier:MAX3243_Reference_Provisional", 43, 30), "DS1": ("Carrier:OLED_26mm_I2C_Provisional", 43, 36), "R1": ("Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal", 43, 34), "R2": ("Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal", 54, 34), "R3": ("Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_Horizontal", 65, 34), "SW1": ("Button_Switch_THT:SW_TH_Tactile_Omron_B3F-100x", 43, 68), "SW2": ("Button_Switch_THT:SW_TH_Tactile_Omron_B3F-100x", 55, 68), "SW3": ("Button_Switch_THT:SW_TH_Tactile_Omron_B3F-100x", 67, 68), } MOUNTS = [(3.5,3.5), (3.5,56), (78,4), (78,76)] # Deliberate allocation, NOT a measured antenna envelope or an RF guarantee. RF_RESERVE = [(10,0), (31,0), (31,13), (10,13)] 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 configure_draft_netclasses(): # SaveBoard creates the project defaults. Align future interactive routing # with the draft's physical minimum widths and its explicit power routing. path=ROOT/"serial-carrier.kicad_pro" project=json.loads(path.read_text()) settings=project["net_settings"] default=next(c for c in settings["classes"] if c["name"]=="Default") default.update(clearance=0.25, track_width=0.25, via_diameter=0.7, via_drill=0.3) power=dict(default, name="Power", priority=0, track_width=0.5, diff_pair_width=0.25) settings["classes"]=[c for c in settings["classes"] if c["name"]!="Power"]+[power] patterns=[v for v in settings.get("netclass_patterns",[]) if v["pattern"] not in ("/+3V3","/GND")] settings["netclass_patterns"]=patterns+[{"netclass":"Power","pattern":name} for name in ("/+3V3","/GND")] path.write_text(json.dumps(project,indent=2)+"\n") 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 any(ROOT.glob("~serial-carrier.*.lck")): parser.error("Project is open in KiCad; close it before regenerating PCB/settings") 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) # SaveBoard persists these proposed draft rules into the project settings. title=board.GetTitleBlock() title.SetTitle("Serial Swiss Army Knife - 82x80 mm carrier draft") title.SetRevision("A0 DRAFT") title.SetComment(0,"NOT FOR FABRICATION - male module / USB / antenna geometry unverified") 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] if ref != "U2": 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,25)) elif ref=="U2": fp.Reference().SetPosition(xy(56.97,18)) elif ref=="DS1": fp.Reference().SetPosition(xy(56,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,7,p.Dwgs_User,1.2) text(board,"VERIFY ANTENNA",20.5,10,p.Dwgs_User,1) text(board,"ESP32 HW678",20.7,29) text(board,"USB ACCESS - VERIFY",18,64,p.Dwgs_User,1) text(board,"DE-9 OUT / VERIFY MALE",56.97,1.5,p.Dwgs_User,1) text(board,"MAX3243 REF",56.97,21) 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,"DRAFT - NOT FOR FAB",56,78,size=0.9) text(board,"82 x 80 mm / 2 copper layers",41,-5,p.Dwgs_User,1.5) text(board,"Carrier envelope only; module overhang and connector clearance not qualified",41,-2.5,p.Dwgs_User,0.9) board.BuildConnectivity() p.SaveBoard(str(PCB),board) configure_draft_netclasses() print("Created",PCB,"with 9 circuit footprints, 4 proposed M3 mounts, 2 copper layers.") print("Placement draft only. Board-only U2 footprint is a female-CAD reference, not male qualification.") if __name__=="__main__": main()