#!/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, 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,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 draft") title.SetRevision("A1 DRAFT") title.SetComment(0,"NOT FOR FABRICATION - USB / antenna geometry pending; verify module pinout / assembly") 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,44)) elif ref=="U2": fp.Reference().SetPosition(xy(56.97,14.670)) 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,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,"USB ACCESS - VERIFY",18,83,p.Dwgs_User,1) 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,"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,"Nominal RS-232 metal verified; ESP32 USB / antenna / assembly clearance pending",41,-2.5,p.Dwgs_User,0.9) board.BuildConnectivity() assert p.SaveBoard(str(PCB),board,True) print("Created",PCB,"with 9 circuit footprints, 4 proposed M3 mounts, 2 copper layers.") print("Placement draft only. U2 nominal male external metal dimensions user-confirmed; pin map / assembly still require qualification.") if __name__=="__main__": main()