Drogheda/scripts/2026-08-12_einarboard30_phaseB_gen_placement.py

598 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
EinarBoard 3.0 — Phase B generator: PCB placement for hbox-watchdog-v5.kicad_pcb
File-level edits per standing rule. Removes Blackpill sockets + tach parts, swaps P6 to
JST B2B 1x02, fabricates+places all Phase-A footprints (dims from KiCad v7 lib files,
syntax from same-board templates), moves Y1/Y2, adds Block-Q mounting holes, updates
net table + title date. Verifies: courtyard overlaps, pad-net consistency, board bounds.
"""
import re, sys, math, uuid, collections
from decimal import Decimal as D
PCB = sys.argv[1] if len(sys.argv) > 1 else 'hbox-watchdog-v5.kicad_pcb'
SCH = sys.argv[2] if len(sys.argv) > 2 else 'hbox-watchdog-v5.kicad_sch'
LIB = '/usr/share/kicad/footprints'
src = open(PCB, encoding='utf-8').read()
if '"U16"' in src:
print('ABORT: U16 already on board'); sys.exit(1)
def NU(): return str(uuid.uuid4())
def endof(s, start):
d = 0; ins = False; esc = False
for k in range(start, len(s)):
c = s[k]
if esc: esc = False; continue
if c == '\\': esc = True; continue
if c == '"': ins = not ins; continue
if not ins:
if c == '(': d += 1
elif c == ')':
d -= 1
if d == 0: return k + 1
return -1
# ---------------- board facts ----------------
X0, X1, Y0, Y1 = 60.19165, 215.19165, 40.3231, 140.3231
# ---------------- net table ----------------
nets = set()
for m in re.finditer(r'\(net "((?:[^"\\]|\\.)*)"\)', src):
nets.add(m.group(1))
NEW_NETS = ['DFU_VBUS','DFU_DP','DFU_DM','DFU_CC1','DFU_CC2','LDO_IN','BOOT0','BOOT1_STRAP',
'VCAP1','VDDA','HSE_IN','HSE_OUT','LSE_IN','LSE_OUT','SWDIO','SWCLK',
'LED_C13','LED_C13_A','LED_PWR_A']
def resolve(name):
if name in nets: return name
if '/'+name in nets: return '/'+name
return None
def N(name):
r = resolve(name)
return f'(net "{r if r else "/"+name}")' # new nets follow the local-label "/" convention
addnets = [n for n in NEW_NETS if resolve(n) is None]
for req in ['NRST','3V3','SYS5V','GND','ETH_INT','5V_FAN','FAN_RET']:
assert resolve(req), f'expected existing net {req}'
# ---------------- footprint blocks ----------------
fp_blocks = [] # (ref, at(x,y,rot), span, block)
for m in re.finditer(r'\(footprint\s+"([^"]+)"', src):
a = m.start(); e = endof(src, a); blk = src[a:e]
rm = re.search(r'\(property "Reference"\s+"([^"]+)"', blk)
am = re.search(r'\(at ([\-0-9.]+) ([\-0-9.]+)( [\-0-9.]+)?\)', blk)
if rm and am:
fp_blocks.append(dict(ref=rm.group(1), lib=m.group(1),
x=float(am.group(1)), y=float(am.group(2)),
rot=float((am.group(3) or '0')), span=(a, e), blk=blk))
def by_ref(r):
c = [f for f in fp_blocks if f['ref'] == r]
assert len(c) == 1, f'{r}: {len(c)}'
return c[0]
def pads_of(blk):
out = []
for m in re.finditer(r'\(pad\s+"([^"]*)"\s+(np_thru_hole|thru_hole|smd)\s+(\w+)', blk):
a = m.start(); e = endof(blk, a); p = blk[a:e]
am = re.search(r'\(at ([\-0-9.]+) ([\-0-9.]+)( [\-0-9.]+)?\)', p)
nm = re.search(r'\(net "((?:[^"\\]|\\.)*)"\)', p)
sm = re.search(r'\(size ([\-0-9.]+) ([\-0-9.]+)\)', p)
out.append(dict(num=m.group(1), kind=m.group(2), shape=m.group(3),
lx=float(am.group(1)), ly=float(am.group(2)),
rot=float((am.group(3) or '0')),
net=nm.group(1) if nm else None,
sx=float(sm.group(1)) if sm else 1, sy=float(sm.group(2)) if sm else 1,
span=(a, e), txt=p))
return out
def rotpt(lx, ly, deg):
r = math.radians(deg % 360)
return (lx*math.cos(r) - ly*math.sin(r), lx*math.sin(r) + ly*math.cos(r))
# validate rotation convention with P12 (rot 90 B2B) vs its connected tracks
P12 = by_ref('P12')
p12p = pads_of(P12['blk'])
cands = {}
for sgn in (1, -1):
pts = []
for p in p12p:
dx, dy = rotpt(p['lx'], p['ly'], sgn*P12['rot'])
pts.append((round(P12['x']+dx, 3), round(P12['y']+dy, 3)))
cands[sgn] = pts
seg_pts = set()
for m in re.finditer(r'\(segment\s*\(start ([\-0-9.]+) ([\-0-9.]+)\)\s*\(end ([\-0-9.]+) ([\-0-9.]+)\)', src):
seg_pts.add((round(float(m.group(1)), 3), round(float(m.group(2)), 3)))
seg_pts.add((round(float(m.group(3)), 3), round(float(m.group(4)), 3)))
score = {1: 0, -1: 0}
segnet = collections.defaultdict(set)
for m in re.finditer(r'\(segment\s*\(start ([\-0-9.]+) ([\-0-9.]+)\)\s*\(end ([\-0-9.]+) ([\-0-9.]+)\)(.*?)\(net "((?:[^"\\]|\\.)*)"\)', src, re.S):
nn = m.group(6)
segnet[nn].add((round(float(m.group(1)), 2), round(float(m.group(2)), 2)))
segnet[nn].add((round(float(m.group(3)), 2), round(float(m.group(4)), 2)))
for f in fp_blocks:
if not f['rot'] % 360: continue
for p in pads_of(f['blk']):
if not p['net'] or p['net'] not in segnet: continue
for sgn in (1, -1):
dx, dy = rotpt(p['lx'], p['ly'], sgn*f['rot'])
pt = (round(f['x']+dx, 2), round(f['y']+dy, 2))
if pt in segnet[p['net']]: score[sgn] += 1
ROTSGN = 1 if score[1] >= score[-1] else -1
print(f'rotation convention: sign={ROTSGN} (net-aware pad-track hits {score})')
def pad_world(f, p):
dx, dy = rotpt(p['lx'], p['ly'], ROTSGN*f['rot'])
return (f['x']+dx, f['y']+dy)
# ---------------- courtyard/occupancy map (AABB per footprint from pads + courtyard rects) ----------------
def fp_bbox(f):
xs = []; ys = []
for p in pads_of(f['blk']):
wx, wy = pad_world(f, p)
xs += [wx - p['sx']/2, wx + p['sx']/2]; ys += [wy - p['sy']/2, wy + p['sy']/2]
for m in re.finditer(r'\(fp_(?:rect|line|circle|poly)\b', f['blk']):
e = endof(f['blk'], m.start()); g = f['blk'][m.start():e]
if '"F.CrtYd"' not in g and '"B.CrtYd"' not in g: continue
for mm in re.finditer(r'\((?:start|end|center|xy) ([\-0-9.]+) ([\-0-9.]+)\)', g):
dx, dy = rotpt(float(mm.group(1)), float(mm.group(2)), ROTSGN*f['rot'])
xs.append(f['x']+dx); ys.append(f['y']+dy)
if not xs: return None
return (min(xs), min(ys), max(xs), max(ys))
REMOVE = {'J1', 'J2', 'Rts1', 'Rtp1', 'P6'}
occ = []
for f in fp_blocks:
if f['ref'] in REMOVE or f['ref'] in ('Y1', 'Y2'): continue
if f['ref'] == 'MOD1':
# header rows + USB/barrel-corner overhang keepout only; under-shield SMD is allowed
pws = [pad_world(f, p) for p in pads_of(f['blk'])]
ys = sorted({round(w[1], 1) for w in pws})
xs = [w[0] for w in pws]
rowA, rowB = min(ys), max(ys)
occ.append(('MOD1-rowA', (min(xs)-1, rowA-1, max(xs)+1, rowA+1)))
occ.append(('MOD1-rowB', (min(xs)-1, rowB-1, max(xs)+1, rowB+1)))
occ.append(('MOD1-ovh', (min(xs)-10, rowB-18.5, min(xs)-0.5, rowB-4.5)))
continue
bb = fp_bbox(f)
if bb: occ.append((f['ref'], bb))
def colliders(bb, margin=0.3):
x0, y0, x1, y1 = bb
hits = []
if x0 < X0+0.8 or y0 < Y0+0.8 or x1 > X1-0.8 or y1 > Y1-0.8: hits.append('BOARD-EDGE')
for ref, (a0, b0, a1, b1) in occ:
if not (x1+margin < a0 or a1+margin < x0 or y1+margin < b0 or b1+margin < y0):
hits.append(ref)
return hits
def clear(bb, margin=0.3):
return not colliders(bb, margin)
def place(w, h, ax, ay, fixed=False, label=''):
"""find position for a w×h courtyard near anchor; returns center"""
if fixed:
bb = (ax-w/2, ay-h/2, ax+w/2, ay+h/2)
assert clear(bb), f'FIXED position blocked: {label} at {ax},{ay}'
occ.append((label, bb)); return (ax, ay)
step = 0.635
for ring in range(0, 40):
for dx in range(-ring, ring+1):
for dy in range(-ring, ring+1):
if max(abs(dx), abs(dy)) != ring: continue
cx, cy = ax + dx*step, ay + dy*step
bb = (cx-w/2, cy-h/2, cx+w/2, cy+h/2)
if clear(bb):
occ.append((label, bb)); return (round(cx, 2), round(cy, 2))
raise RuntimeError(f'no space for {label} near {ax},{ay}')
# ---------------- sch symbol uuid map (for footprint path linkage) ----------------
schsrc = open(SCH, encoding='utf-8').read()
sch_uuid = {}
for m in re.finditer(r'\(symbol\b', schsrc):
e = endof(schsrc, m.start()); b = schsrc[m.start():e]
um = re.search(r'\(uuid "([^"]+)"\)', b)
rm = re.search(r'\(property "Reference"\s+"([^"]+)"', b)
if um and rm and rm.group(1) not in sch_uuid:
sch_uuid[rm.group(1)] = um.group(1)
# ---------------- v7 lib dims ----------------
def lib_dims(path):
s = open(path).read()
pads = []
for m in re.finditer(r'\(pad\s+"?([^"\s]*)"?\s+(np_thru_hole|thru_hole|smd)\s+(\w+)', s):
e = endof(s, m.start()); p = s[m.start():e]
am = re.search(r'\(at ([\-0-9.]+) ([\-0-9.]+)( [\-0-9.]+)?\)', p)
sm = re.search(r'\(size ([\-0-9.]+) ([\-0-9.]+)\)', p)
dm = re.search(r'\(drill ([\-0-9.]+)', p)
rr = re.search(r'\(roundrect_rratio ([\-0-9.]+)\)', p)
lay = re.search(r'\(layers ([^)]+)\)', p)
pads.append(dict(num=m.group(1).strip('"'), kind=m.group(2), shape=m.group(3),
x=float(am.group(1)), y=float(am.group(2)), rot=float((am.group(3) or '0')),
sx=float(sm.group(1)), sy=float(sm.group(2)),
drill=float(dm.group(1)) if dm else None,
rratio=rr.group(1) if rr else None, layers=lay.group(1)))
cxs = []; cys = []
for m in re.finditer(r'\(fp_(?:rect|line|circle)\b', s):
e = endof(s, m.start()); g = s[m.start():e]
if 'CrtYd' not in g: continue
for mm in re.finditer(r'\((?:start|end|center) ([\-0-9.]+) ([\-0-9.]+)\)', g):
cxs.append(float(mm.group(1))); cys.append(float(mm.group(2)))
crt = (min(cxs), min(cys), max(cxs), max(cys)) if cxs else None
return dict(pads=pads, crt=crt)
LQFP64 = lib_dims(f'{LIB}/Package_QFP.pretty/LQFP-64_10x10mm_P0.5mm.kicad_mod')
SOT223 = lib_dims(f'{LIB}/Package_TO_SOT_SMD.pretty/SOT-223-3_TabPin2.kicad_mod')
SOD323 = lib_dims(f'{LIB}/Diode_SMD.pretty/D_SOD-323.kicad_mod')
X3215 = lib_dims(f'{LIB}/Crystal.pretty/Crystal_SMD_3215-2Pin_3.2x1.5mm.kicad_mod')
LED063 = lib_dims(f'{LIB}/LED_SMD.pretty/LED_0603_1608Metric.kicad_mod')
UNOH = lib_dims(f'{LIB}/Module.pretty/Arduino_UNO_R3_WithMountingHoles.kicad_mod')
HOLES_LOCAL = [(p['x'], p['y']) for p in UNOH['pads'] if p['kind'] == 'np_thru_hole']
assert len(HOLES_LOCAL) == 4
# MOD1 transform fit: board pad locals vs library pad locals (plain UNO, pad1..32)
UNO = lib_dims(f'{LIB}/Module.pretty/Arduino_UNO_R3.kicad_mod')
MOD1 = by_ref('MOD1')
bp = {p['num']: (p['lx'], p['ly']) for p in pads_of(MOD1['blk'])}
lp = {p['num']: (p['x'], p['y']) for p in UNO['pads']}
common = [k for k in ('1', '2', '11', '20', '32') if k in bp and k in lp]
assert len(common) >= 3, f'MOD1 pads for fit: {common}'
# solve: board_local = (sx * lib_x, sy * lib_y) with sx,sy in {+1,-1} + offset
fits = []
for sx in (1, -1):
for sy in (1, -1):
offs = {(round(bp[k][0]-sx*lp[k][0], 3), round(bp[k][1]-sy*lp[k][1], 3)) for k in common}
if len(offs) == 1: fits.append((sx, sy, offs.pop()))
assert len(fits) == 1, f'MOD1 fit ambiguous: {fits}'
MSX, MSY, (MOX, MOY) = fits[0]
print(f'MOD1 mirror fit: sx={MSX} sy={MSY} off=({MOX},{MOY})')
shield_holes = []
for hx, hy in HOLES_LOCAL:
blx, bly = MSX*hx + MOX, MSY*hy + MOY
dx, dy = rotpt(blx, bly, ROTSGN*MOD1['rot'])
shield_holes.append((round(MOD1['x']+dx, 3), round(MOD1['y']+dy, 3)))
print('shield bolt holes (world):', shield_holes)
# ---------------- templates from board ----------------
T_R = by_ref('R20'); T_C = None; T_C0805 = None
for f in fp_blocks:
if 'C_0603' in f['lib'] and T_C is None: T_C = f
if 'C_0805' in f['lib'] and T_C0805 is None: T_C0805 = f
assert T_C, 'no 0603 C template'
T_SMA = by_ref('D2'); T_TP = by_ref('TP1'); T_SW = by_ref('SW1'); T_MH = by_ref('MH2')
T_B2B = by_ref('P12'); T_USB = by_ref('J9'); T_LQFP = by_ref('U11'); T_HC49 = by_ref('Y1')
P6old = by_ref('P6')
def path_of(blk):
m = re.search(r'\(path "([^"]+)"\)', blk)
return m.group(1) if m else None
def retarget(tmpl_f, ref, value, libname, x, y, rot, padnets, fpname=None):
"""clone a board template block: new ref/value/lib/at/uuids/nets/path"""
t = tmpl_f['blk']
t = re.sub(r'\(footprint\s+"[^"]+"', f'(footprint "{libname}"', t, count=1)
t = re.sub(r'\(at [\-0-9. ]+\)', f'(at {x:g} {y:g}{(" %g"%rot) if rot else ""})', t, count=1)
t = re.sub(r'\(property "Reference"\s+"[^"]+"', f'(property "Reference" "{ref}"', t, count=1)
t = re.sub(r'\(property "Value"\s+"[^"]*"', f'(property "Value" "{value}"', t, count=1)
t = re.sub(r'\(property "Footprint"\s+"[^"]*"', f'(property "Footprint" "{libname}"', t, count=1)
t = re.sub(r'\(uuid "[^"]*"\)', lambda m: f'(uuid "{NU()}")', t)
su = sch_uuid.get(ref)
if su: t = re.sub(r'\(path "[^"]*"\)', f'(path "/{su}")', t, count=1)
# pad rotation update: template rot -> new rot on every pad angle token + nets
dr = rot - tmpl_f['rot']
def fixpad(m):
a = m.start(); e = endof(t, a); p = t[a:e]
num = re.match(r'\(pad\s+"([^"]*)"', p).group(1)
if dr:
def bump(mm):
ang = (float(mm.group(3) or 0) + dr) % 360
return f'(at {mm.group(1)} {mm.group(2)}{(" %g"%ang) if ang else ""})'
p2 = re.sub(r'\(at ([\-0-9.]+) ([\-0-9.]+)(?: ([\-0-9.]+))?\)', bump, p, count=1)
else: p2 = p
if num in padnets:
if '(net ' in p2: p2 = re.sub(r'\(net "(?:[^"\\]|\\.)*"\)', N(padnets[num]), p2)
else: p2 = p2[:-1] + '\n\t\t' + N(padnets[num]) + '\n\t)'
else:
p2 = re.sub(r'\s*\(net "(?:[^"\\]|\\.)*"\)', '', p2)
return p2
out = []; i = 0
for m in list(re.finditer(r'\(pad\s+"[^"]*"', t)):
a = m.start()
if a < i: continue
e = endof(t, a)
out.append(t[i:a]);
# rebuild pad via fixpad logic inline
p = t[a:e]
num = re.match(r'\(pad\s+"([^"]*)"', p).group(1)
if dr:
def bump(mm):
ang = (float(mm.group(3) or 0) + dr) % 360
return f'(at {mm.group(1)} {mm.group(2)}{(" %g"%ang) if ang else ""})'
p = re.sub(r'\(at ([\-0-9.]+) ([\-0-9.]+)(?: ([\-0-9.]+))?\)', bump, p, count=1)
if num in padnets:
if '(net ' in p: p = re.sub(r'\(net "(?:[^"\\]|\\.)*"\)', N(padnets[num]), p)
else: p = p[:-1] + '\n\t\t\t' + N(padnets[num]) + '\n\t\t)'
else:
p = re.sub(r'\s*\(net "(?:[^"\\]|\\.)*"\)', '', p)
out.append(p); i = e
out.append(t[i:])
return ''.join(out)
def build_generic(ref, value, libname, x, y, rot, dims, padnets, attr='smd', tmpl=None):
"""fabricate a footprint from v7 dims using a same-class board template for syntax"""
tmpl = tmpl or (T_C if attr == 'smd' else T_B2B)
t = tmpl['blk']
head = re.match(r'(\(footprint\s+"[^"]+"\s*(?:\(version[^)]*\))?\s*(?:\(generator[^)]*\))?\s*(?:\(generator_version[^)]*\))?\s*\(layer "F\.Cu"\))', t)
# properties section: reuse template's property blocks (Reference/Value/Footprint/Datasheet/Description) + attr + path
props = []
for pm in re.finditer(r'\(property "([^"]+)"\s+"[^"]*"', t):
a = pm.start(); e = endof(t, a); props.append((pm.group(1), t[a:e]))
prop_txt = []
for name, ptxt in props:
if name == 'Reference': ptxt = re.sub(r'"Reference"\s+"[^"]+"', f'"Reference" "{ref}"', ptxt, count=1)
elif name == 'Value': ptxt = re.sub(r'"Value"\s+"[^"]*"', f'"Value" "{value}"', ptxt, count=1)
elif name == 'Footprint': ptxt = re.sub(r'"Footprint"\s+"[^"]*"', f'"Footprint" "{libname}"', ptxt, count=1)
ptxt = re.sub(r'\(uuid "[^"]*"\)', lambda m: f'(uuid "{NU()}")', ptxt)
prop_txt.append(ptxt)
attrm = re.search(r'\(attr [^)]*\)', t)
attr_txt = attrm.group(0) if attrm else f'(attr {attr})'
if attr == 'smd' and 'smd' not in attr_txt: attr_txt = '(attr smd)'
su = sch_uuid.get(ref)
path_txt = f'(path "/{su}")' if su else ''
sheet = re.search(r'\(sheetfile "[^"]*"\)', t)
sheet_txt = (re.search(r'\(sheetname "[^"]*"\)', t).group(0) + '\n\t\t' + sheet.group(0)) if sheet else ''
pads = []
for p in dims['pads']:
ang = (p['rot'] + rot) % 360
angtxt = f' {ang:g}' if ang else ''
drill = f'\n\t\t\t(drill {p["drill"]:g})' if p['drill'] else ''
rr = f'\n\t\t\t(roundrect_rratio {p["rratio"]})' if p['rratio'] else ''
nettxt = f'\n\t\t\t{N(padnets[p["num"]])}' if p['num'] in padnets else ''
layers = p['layers'].replace('*.Cu *.Mask', '"*.Cu" "*.Mask"') if '"' not in p['layers'] else p['layers']
pads.append(f'''\t\t(pad "{p['num']}" {p['kind']} {p['shape']}
\t\t\t(at {p['x']:g} {p['y']:g}{angtxt})
\t\t\t(size {p['sx']:g} {p['sy']:g}){drill}
\t\t\t(layers {layers}){rr}{nettxt}
\t\t\t(uuid "{NU()}")
\t\t)''')
crt = dims['crt']
crt_txt = ''
if crt:
crt_txt = f'''\t\t(fp_rect
\t\t\t(start {crt[0]:g} {crt[1]:g})
\t\t\t(end {crt[2]:g} {crt[3]:g})
\t\t\t(stroke
\t\t\t\t(width 0.05)
\t\t\t\t(type default)
\t\t\t)
\t\t\t(fill no)
\t\t\t(layer "F.CrtYd")
\t\t\t(uuid "{NU()}")
\t\t)'''
nl = '\n\t'
return f'''(footprint "{libname}"
\t\t(layer "F.Cu")
\t\t(uuid "{NU()}")
\t\t(at {x:g} {y:g}{(" %g"%rot) if rot else ""})
\t\t{nl.join(prop_txt)}
\t\t{path_txt}
\t\t{sheet_txt}
\t\t{attr_txt}
{crt_txt}
{chr(10).join(pads)}
\t)'''
INS = []
report = collections.defaultdict(list)
# ---------------- deletions ----------------
del_spans = [by_ref(r)['span'] for r in ('J1', 'J2', 'Rts1', 'Rtp1', 'P6')]
report['removed'] = ['J1', 'J2', 'Rts1', 'Rtp1', 'P6(1x04)']
# ---------------- P6 swap (B2B clone at old position) ----------------
p6nets = {p['num']: p['net'] for p in pads_of(P6old['blk']) if p['net']}
INS.append(retarget(T_B2B, 'P6', 'FAN 2-pin', 'Connector_JST:JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical',
P6old['x'], P6old['y'], P6old['rot'],
{'1': p6nets.get('1', '5V_FAN'), '2': p6nets.get('2', 'FAN_RET')}))
b = fp_bbox(dict(blk=INS[-1], x=P6old['x'], y=P6old['y'], rot=P6old['rot']))
occ.append(('P6new', b))
report['placed'].append(('P6', P6old['x'], P6old['y'], 'B2B swap in place'))
# ---------------- relocate W5500-era passives out of the U16 zone ----------------
RELOC = ['C102', 'C90', 'C91', 'C96', 'R96', 'R97']
occ[:] = [(r, bb) for r, bb in occ if r not in RELOC]
# ---------------- U16 ----------------
w = LQFP64['crt'][2]-LQFP64['crt'][0]; h = LQFP64['crt'][3]-LQFP64['crt'][1]
ux, uy = place(w+0.4, h+0.4, 94.0, 71.0, fixed=True, label='U16')
for rref in RELOC:
rf = by_ref(rref)
rbb = fp_bbox(rf); rw, rh = rbb[2]-rbb[0], rbb[3]-rbb[1]
nx, ny = place(rw+0.2, rh+0.2, 96.0, 57.0, label=rref)
del_spans.append(rf['span'])
INS.append(re.sub(r'\(at [\-0-9. ]+\)', f'(at {nx:g} {ny:g}{(" %g"%rf["rot"]) if rf["rot"] else ""})', rf['blk'], count=1))
report['placed'].append((rref, nx, ny, 'relocated (was in U16 zone)'))
MCU_NETS = {'1':'3V3','2':'LED_C13','3':'LSE_IN','4':'LSE_OUT','5':'HSE_IN','6':'HSE_OUT',
'7':'NRST','8':'ETH_INT','12':'GND','13':'VDDA','14':'BAT_SENSE','15':'PWR_CTRL','16':'GNSS_TX',
'17':'GNSS_RX','18':'GND','19':'3V3','20':'5V_SENSE','21':'IGN_SENSE','22':'FAN_PWM','26':'EXT_LED',
'27':'NTC_TEMP','28':'BOOT1_STRAP','30':'VCAP1','31':'GND','32':'3V3','33':'ETH_CS','34':'ETH_SCK',
'35':'ETH_MISO','36':'ETH_MOSI','41':'CHRG_EN','42':'USART1_TX','43':'USART1_RX','44':'DFU_DM',
'45':'DFU_DP','46':'SWDIO','47':'GND','48':'3V3','49':'SWCLK','50':'ETH_RST','55':'CHRG_STAT',
'56':'CHRG_FAULT','57':'BYP_CTRL','58':'I2C_SCL','59':'I2C_SDA','60':'BOOT0','61':'SER_IRQ',
'63':'GND','64':'3V3'}
for extra in ['BAT_SENSE','PWR_CTRL','GNSS_TX','GNSS_RX','5V_SENSE','IGN_SENSE','FAN_PWM','EXT_LED',
'NTC_TEMP','ETH_CS','ETH_SCK','ETH_MISO','ETH_MOSI','CHRG_EN','USART1_TX','USART1_RX',
'SER_IRQ','CHRG_STAT','CHRG_FAULT','BYP_CTRL','I2C_SCL','I2C_SDA','ETH_RST']:
assert resolve(extra), f'net {extra} not in pcb'
INS.append(build_generic('U16', 'STM32F411RET6', 'Package_QFP:LQFP-64_10x10mm_P0.5mm',
ux, uy, 180, LQFP64, MCU_NETS, tmpl=T_LQFP))
report['placed'].append(('U16', ux, uy, 'rot180'))
def small(ref, value, lib, dims, n1, n2, ax, ay, tmpl=None, rot=0):
if dims and dims['crt']:
ww = dims['crt'][2]-dims['crt'][0]; hh = dims['crt'][3]-dims['crt'][1]
elif tmpl is not None:
tb = fp_bbox(dict(blk=tmpl['blk'], x=0, y=0, rot=tmpl['rot']))
ww, hh = tb[2]-tb[0]+0.4, tb[3]-tb[1]+0.4
else:
ww, hh = 2.4, 1.4
if rot in (90, 270): ww, hh = hh, ww
cx, cy = place(ww+0.2, hh+0.2, ax, ay, label=ref)
padnets = {'1': n1, '2': n2}
if dims: INS.append(build_generic(ref, value, lib, cx, cy, rot, dims, padnets, tmpl=tmpl))
else: INS.append(retarget(tmpl, ref, value, lib, cx, cy, rot, padnets))
report['placed'].append((ref, cx, cy, value))
return cx, cy
R_LIB = 'Resistor_SMD:R_0603_1608Metric'; C_LIB = 'Capacitor_SMD:C_0603_1608Metric'
# clone 0603 R/C via retarget (same lib) — dims=None path
def r0603(ref, val, n1, n2, ax, ay): return small(ref, val, R_LIB, None, n1, n2, ax, ay, tmpl=T_R)
def c0603(ref, val, n1, n2, ax, ay): return small(ref, val, C_LIB, None, n1, n2, ax, ay, tmpl=T_C)
# decoupling ring around U16
c0603('C105', '100nF', '3V3', 'GND', ux-7.5, uy-7.5)
c0603('C106', '100nF', '3V3', 'GND', ux+7.5, uy-7.5)
c0603('C107', '100nF', '3V3', 'GND', ux-7.5, uy+7.5)
c0603('C108', '100nF', '3V3', 'GND', ux+7.5, uy+7.5)
c0603('C110', '4.7uF', '3V3', 'GND', ux, uy-8.2)
c0603('C111', '4.7uF', 'VCAP1', 'GND', ux, uy+8.2)
c0603('C104', '100nF', 'NRST', 'GND', ux-8.2, uy-5)
r0603('R99', '10k', 'BOOT1_STRAP', 'GND', ux-8.2, uy+3)
# VDDA filter south
r0603('R104', '10R', '3V3', 'VDDA', ux-3, uy+8.9)
c0603('C109', '100nF', 'VDDA', 'GND', ux+3, uy+8.9)
c0603('C112', '1uF', 'VDDA', 'GND', ux+5.8, uy+8.9)
# crystals east of U16
small('Y3', '25MHz', T_HC49['lib'], None, 'HSE_IN', 'HSE_OUT', ux+11, uy+3, tmpl=T_HC49)
c0603('C113', '15pF', 'HSE_IN', 'GND', ux+11, uy+6.5)
c0603('C114', '15pF', 'HSE_OUT', 'GND', ux+14, uy+6.5)
small('Y4', '32.768kHz', 'Crystal:Crystal_SMD_3215-2Pin_3.2x1.5mm', X3215, 'LSE_IN', 'LSE_OUT', ux+11, uy-3)
c0603('C115', '12pF', 'LSE_IN', 'GND', ux+14, uy-3)
c0603('C116', '12pF', 'LSE_OUT', 'GND', ux+14, uy-0.5)
# BOOT0 pulldown near MCU
r0603('R98', '10k', 'BOOT0', 'GND', ux+8.2, uy+3)
# J11 USB-C at left edge (fixed) — clone J9, rot per convention: mouth (-x)
for rref in ['C83']:
rf = by_ref(rref)
occ[:] = [(r, bb) for r, bb in occ if r != rref]
rbb = fp_bbox(rf); rw, rh = rbb[2]-rbb[0], rbb[3]-rbb[1]
nx, ny = place(rw+0.2, rh+0.2, 79.0, 86.0, label=rref)
del_spans.append(rf['span'])
INS.append(re.sub(r'\(at [\-0-9. ]+\)', f'(at {nx:g} {ny:g}{(" %g"%rf["rot"]) if rf["rot"] else ""})', rf['blk'], count=1))
report['placed'].append((rref, nx, ny, 'relocated (was in J11 zone)'))
mouth_rot = 90 if ROTSGN == 1 else 270
jx, jy = 71.11, 92.0
jblk = retarget(T_USB, 'J11', 'USB-C DFU', T_USB['lib'], jx, jy, mouth_rot,
{'A1':'GND','B1':'GND','A12':'GND','B12':'GND',
'A4':'DFU_VBUS','A9':'DFU_VBUS','B4':'DFU_VBUS','B9':'DFU_VBUS',
'A5':'DFU_CC1','B5':'DFU_CC2','A6':'DFU_DP','B6':'DFU_DP',
'A7':'DFU_DM','B7':'DFU_DM','A8':'','B8':'','SH':'GND'})
bb = fp_bbox(dict(blk=jblk, x=jx, y=jy, rot=mouth_rot))
assert clear(bb, 0.25), f'J11 blocked at {bb} by {colliders(bb, 0.25)}'
occ.append(('J11', bb)); INS.append(jblk)
report['placed'].append(('J11', jx, jy, f'rot{mouth_rot} mouth-west'))
# CC + TVS east of J11
r0603('R100', '5.1k', 'DFU_CC1', 'GND', 79.5, 89)
r0603('R101', '5.1k', 'DFU_CC2', 'GND', 79.5, 91.5)
small('D14', 'PESD5V0S1BA', 'Diode_SMD:D_SOD-323', SOD323, 'DFU_DP', 'GND', 79.5, 94)
small('D15', 'PESD5V0S1BA', 'Diode_SMD:D_SOD-323', SOD323, 'DFU_DM', 'GND', 79.5, 96.5)
small('D16', 'PESD5V0S1BA', 'Diode_SMD:D_SOD-323', SOD323, 'DFU_VBUS', 'GND', 79.5, 99)
# LDO cluster (under shield, SMD)
small('D12', 'SS34', T_SMA['lib'], None, 'LDO_IN', 'SYS5V', 84, 101, tmpl=T_SMA)
small('D13', 'SS34', T_SMA['lib'], None, 'LDO_IN', 'DFU_VBUS', 84, 104.5, tmpl=T_SMA)
sx_, sy_ = place((SOT223['crt'][2]-SOT223['crt'][0])+0.2, (SOT223['crt'][3]-SOT223['crt'][1])+0.2, 91, 102.5, label='U17')
INS.append(build_generic('U17', 'AMS1117-3.3', 'Package_TO_SOT_SMD:SOT-223-3_TabPin2', sx_, sy_, 0,
SOT223, {'1':'GND','2':'3V3','3':'LDO_IN','4':'3V3'}))
report['placed'].append(('U17', sx_, sy_, 'AMS1117'))
ctmpl = T_C0805 or T_C
small('C117', '10uF', 'Capacitor_SMD:C_0805_2012Metric' if T_C0805 else C_LIB, None, 'LDO_IN', 'GND', 96.5, 100, tmpl=ctmpl)
small('C118', '22uF/16V', 'Capacitor_SMD:C_0805_2012Metric' if T_C0805 else C_LIB, None, '3V3', 'GND', 96.5, 103, tmpl=ctmpl)
# LEDs + resistors near USB (visible, not under shield)
small('D17', 'LED C13 (blue)', 'LED_SMD:LED_0603_1608Metric', LED063, 'LED_C13', 'LED_C13_A', 64.5, 84)
small('D18', 'LED PWR (green)', 'LED_SMD:LED_0603_1608Metric', LED063, 'GND', 'LED_PWR_A', 64.5, 86.6)
r0603('R102', '1k', '3V3', 'LED_C13_A', 67.3, 84)
r0603('R103', '1k', '3V3', 'LED_PWR_A', 67.3, 86.6)
# SW2 BOOT0 next to SW1
swp = pads_of(T_SW['blk']); swnets = {p['num'] for p in swp}
sw_nets = {}
for p in swp:
sw_nets[p['num']] = 'BOOT0' if p['net'] and 'RESET' not in (p['net'] or '') else None
# simple: SW_Push pads 1/2 -> 3V3/BOOT0 (both pads of each side share)
padmap = {}
for p in swp:
padmap[p['num']] = '3V3' if p['num'] in ('1', '3') else 'BOOT0'
sxw, syw = 191.0, 133.3
blk = retarget(T_SW, 'SW2', 'BOOT0', T_SW['lib'], sxw, syw, T_SW['rot'], padmap)
bb = fp_bbox(dict(blk=blk, x=sxw, y=syw, rot=T_SW['rot']))
if not clear(bb, 0.25):
placed = False
for ax, ay in [(66, 81), (203, 127.5), (155.5, 133), (189, 126), (127, 60)]:
try:
sxw, syw = place(bb[2]-bb[0]+0.4, bb[3]-bb[1]+0.4, ax, ay, label='SW2')
placed = True; break
except RuntimeError:
continue
assert placed, 'SW2: no home found'
blk = retarget(T_SW, 'SW2', 'BOOT0', T_SW['lib'], sxw, syw, T_SW['rot'], padmap)
else:
occ.append(('SW2', bb))
INS.append(blk); report['placed'].append(('SW2', sxw, syw, 'BOOT0 button'))
# SWD test points row (north strip)
for i, (ref, net) in enumerate([('TP3','SWDIO'),('TP4','SWCLK'),('TP5','NRST'),('TP6','GND'),('TP7','3V3')]):
txp, typ = place(2.2, 2.2, 116+i*2.8, 57.5, label=ref)
INS.append(retarget(T_TP, ref, net, T_TP['lib'], txp, typ, 0, {'1': net}))
report['placed'].append((ref, txp, typ, net))
# Y1 / Y2 moves: rewrite (at) in their blocks
Y1f = by_ref('Y1'); Y2f = by_ref('Y2')
y1x, y1y = place(12.4, 6.0, 104.5, 56.5, label='Y1')
y2x, y2y = place(12.4, 6.0, 80.0, 106.5, label='Y2')
def move_block(f, nx, ny):
nb = re.sub(r'\(at [\-0-9. ]+\)', f'(at {nx:g} {ny:g}{(" %g"%f["rot"]) if f["rot"] else ""})', f['blk'], count=1)
return nb
del_spans.append(Y1f['span']); INS.append(move_block(Y1f, y1x, y1y))
del_spans.append(Y2f['span']); INS.append(move_block(Y2f, y2x, y2y))
report['placed'].append(('Y1', y1x, y1y, 'moved east')); report['placed'].append(('Y2', y2x, y2y, 'moved to U10'))
# Block Q holes: 4 shield (fixed) + 2 XBee
mh_tmpl = T_MH['blk']
def add_hole(ref, x, y, drill):
t = mh_tmpl
t = re.sub(r'\(property "Reference"\s+"[^"]+"', f'(property "Reference" "{ref}"', t, count=1)
t = re.sub(r'\(property "Value"\s+"[^"]*"', f'(property "Value" "MountingHole_M2.5"', t, count=1)
t = re.sub(r'\(at [\-0-9. ]+\)', f'(at {x:g} {y:g})', t, count=1)
t = re.sub(r'\(drill [0-9.]+\)', f'(drill {drill:g})', t)
t = re.sub(r'\(size [0-9.]+ [0-9.]+\)', f'(size {drill:g} {drill:g})', t)
t = re.sub(r'\(uuid "[^"]*"\)', lambda m: f'(uuid "{NU()}")', t)
bb2 = (x-drill/2-0.5, y-drill/2-0.5, x+drill/2+0.5, y+drill/2+0.5)
ok = clear(bb2, 0.25)
occ.append((ref, bb2)); INS.append(t)
report['holes'].append((ref, x, y, drill, 'CLEAR' if ok else '⚠ TIGHT — review'))
for i, (hx, hy) in enumerate(shield_holes):
add_hole(f'MH{7+i}', hx, hy, 2.75)
add_hole('MH11', 178.0, 60.5, 2.75)
add_hole('MH12', 185.35, 91.5, 2.75)
# ---------------- title date ----------------
text_patches = []
m = re.search(r'\(title_block.*?\(date "([^"]*)"\)', src, re.S)
if m:
a = m.start(1) - 7; b = m.end(1) + 2
text_patches.append((m.start(1), m.end(1), '2026-08-12'))
# ---------------- splice ----------------
new = src
ops = [(a, b, '') for a, b in del_spans] + [(a, b, t) for a, b, t in text_patches]
ops.sort(key=lambda o: -o[0])
spansorted = sorted([(a, b) for a, b, _ in ops])
for (a1, b1), (a2, b2) in zip(spansorted, spansorted[1:]):
assert b1 <= a2, 'overlap'
for a, b, t in ops:
if t == '':
a0 = a
while a0 > 0 and new[a0-1] in ' \t\n': a0 -= 1
new = new[:a0] + '\n\t' + new[b:]
else:
new = new[:a] + t + new[b:]
# KiCad 10: no numbered net table — nets exist via pad references only
# insert footprints before final paren
end = new.rfind(')')
new = new[:end] + '\t' + '\n\t'.join(INS) + '\n' + new[end:]
open(PCB, 'w', encoding='utf-8').write(new)
print(f'WROTE {PCB}: {len(src)} -> {len(new)} bytes; balance={new.count("(")==new.count(")")}')
print(f'new nets: {addnets}')
for k in ('removed', 'placed', 'holes'):
print(f'-- {k}:')
for item in report[k]: print(' ', item)