Files
ESP32_Serial_Swiss_Army_Knife/hardware/PCB/generate_pcb_draft.py
T
Commander1024 901afbe754 Integrate Measured ESP32 PCB Geometry
- Add measured F.Fab bounds and USB mouth markers
- Add guarded preservation and regression validation
- Refresh review exports and remove draft presentation labels
2026-09-21 22:54:37 +02:00

207 lines
8.4 KiB
Python

#!/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")
title.SetRevision("A1")
title.SetComment(0,"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,"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()