Files
Commander1024 c257158ada Record Accepted PCB Placement And Fit
Align component groups, repair affected routing, and add preservation
audits.
Assign the confirmed U2 footprint and update pull-ups to 2 kΩ across
design files.
Document user acceptance, switch to an A3 worksheet, and refresh PCB
exports.
2026-09-22 10:28:08 +02:00

210 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.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()