598 lines
29 KiB
Python
598 lines
29 KiB
Python
#!/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)
|