app
Component Widgets
Public Made by Adomby adom
Reusable component widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and lightweight four-level LOD cache listings.
master
John Lauer
Add adom-verified, adom-models and adom-footprint-variants (moved from john/ pages) with their tools
7acd92d
16d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
#!/usr/bin/env python3
"""make-variants.py — generate the Adom footprint-variant family from a baseline .kicad_mod.
Reproduces the adom-footprint 1.0.22 heatsink / solderballs rules (as recorded in
adom/lan7800's heatsink-vias.json and solder-balls.json) so a part can ship the
family before those verbs are published:
<base>-HV.kicad_mod heatsink vias: thru_hole pads under the exposed pad, same pad number
<base>-SB.kicad_mod solder balls: filled fp_circle O0.30 mm on User.9 (Adom.SolderBalls), 0.25 mm pitch
<base>-RR.kicad_mod rounded pads: every SMD pad -> roundrect (signal 0.25, EP 0.1)
<base>-HV-SB-RR.kicad_mod all three
<base>.variants.json the manifest <adom-footprint-variants src=...> reads
<base>.heatsink-vias.json / <base>-SB.solder-balls.json rule files
usage: make-variants.py <baseline.kicad_mod> --part LAN7800 --package "QFN-48 7x7mm" [--ep 49] [--out DIR]
"""
import argparse, json, math, os, re, sys
VIA_PITCH, VIA_DRILL, VIA_OD, VIA_INSET, BALL_VIA_CLEAR = 1.2, 0.3, 0.6, 0.45, 0.55
BALL_D, BALL_PITCH = 0.30, 0.25
RR_SIGNAL, RR_EP = 0.25, 0.1
def tokens(text):
return re.findall(r'"(?:[^"\\]|\\.)*"|[()]|[^\s()"]+', text)
def parse(text):
toks = tokens(text); pos = 0
def walk():
nonlocal pos
out = []
while pos < len(toks):
t = toks[pos]; pos += 1
if t == '(': out.append(walk())
elif t == ')': return out
else: out.append(t)
return out
root = []
while pos < len(toks):
t = toks[pos]; pos += 1
if t == '(': root.append(walk())
return root[0]
def find(node, key):
return next((x for x in node if isinstance(x, list) and x and x[0] == key), None)
def pads(root):
out = []
for n in root:
if isinstance(n, list) and n and n[0] == 'pad':
at, size = find(n, 'at'), find(n, 'size')
out.append({'num': n[1].strip('"'), 'kind': n[2], 'shape': n[3],
'x': float(at[1]), 'y': float(at[2]), 'w': float(size[1]), 'h': float(size[2])})
return out
def ep_pad(ps, ep):
cands = [p for p in ps if p['num'] == str(ep)] if ep else []
if not cands:
cands = [max(ps, key=lambda p: p['w'] * p['h'])]
return cands[0]
def via_grid(ep):
half_w, half_h = ep['w'] / 2 - VIA_INSET - VIA_OD / 2, ep['h'] / 2 - VIA_INSET - VIA_OD / 2
nx = int(math.floor(2 * half_w / VIA_PITCH)) + 1
ny = int(math.floor(2 * half_h / VIA_PITCH)) + 1
xs = [ep['x'] + (i - (nx - 1) / 2) * VIA_PITCH for i in range(nx)]
ys = [ep['y'] + (j - (ny - 1) / 2) * VIA_PITCH for j in range(ny)]
return [{'x': round(x, 3), 'y': round(y, 3)} for y in ys for x in xs]
BALL_INSET, RING_OUTER = 0.10, 0.80
def ball_grid(p, vias=(), is_ep=False):
"""0.25 mm pitch grid of O0.30 balls inside a pad (0.10 mm inset), centred.
Signal pads fill completely. The exposed pad follows the adom-solder-ball-layer
standard: a perimeter ring plus a ring around every heatsink-via position
(0.55 mm keep-out to the via centre, ring out to 0.80 mm), never a solid fill."""
def axis(c, span):
n = int(math.floor((span - 2 * BALL_INSET - BALL_D) / BALL_PITCH)) + 1
n = max(n, 1)
return [c + (i - (n - 1) / 2) * BALL_PITCH for i in range(n)]
xs, ys = axis(p['x'], p['w']), axis(p['y'], p['h'])
out = []
for j, y in enumerate(ys):
for i, x in enumerate(xs):
d = min((math.hypot(x - v['x'], y - v['y']) for v in vias), default=1e9)
if d < BALL_VIA_CLEAR:
continue
if is_ep:
perimeter = i in (0, len(xs) - 1) or j in (0, len(ys) - 1)
if not (perimeter or d < RING_OUTER):
continue
out.append({'x': round(x, 3), 'y': round(y, 3)})
return out
def strip_footer(text):
"""Return (head, tail) so new nodes go before the closing paren of (footprint ...)."""
i = text.rstrip().rfind(')')
return text[:i], text[i:]
def rename(text, suffix):
return re.sub(r'^\(footprint "([^"]+)"', lambda m: '(footprint "%s%s"' % (m.group(1), suffix), text, count=1)
def with_roundrect(text):
def fix(m):
num, kind, shape, body = m.group(1), m.group(2), m.group(3), m.group(4)
if kind != 'smd' or shape == 'roundrect':
return m.group(0)
size = re.search(r'\(size ([\d.]+) ([\d.]+)\)', body)
big = size and float(size.group(1)) * float(size.group(2)) > 4.0
rr = RR_EP if big else RR_SIGNAL
body = re.sub(r'(\(size [^)]*\))', r'\1\n\t\t(roundrect_rratio %s)' % rr, body, count=1)
return '(pad "%s" %s roundrect%s' % (num, kind, body)
return re.sub(r'\(pad "([^"]+)" (\w+) (\w+)((?:(?!\n\t\(pad ).)*?\n\t\))', fix, text, flags=re.S)
def via_nodes(vias, num):
return ''.join('\t(pad "%s" thru_hole circle\n\t\t(at %g %g)\n\t\t(size %g %g)\n\t\t(drill %g)\n\t\t(layers "*.Cu" "*.Mask")\n\t)\n'
% (num, v['x'], v['y'], VIA_OD, VIA_OD, VIA_DRILL) for v in vias)
def ball_nodes(balls):
r = BALL_D / 2
return ''.join('\t(fp_circle\n\t\t(center %g %g)\n\t\t(end %g %g)\n\t\t(stroke\n\t\t\t(width 0)\n\t\t\t(type solid)\n\t\t)\n\t\t(fill yes)\n\t\t(layer "User.9")\n\t)\n'
% (b['x'], b['y'], b['x'] + r, b['y']) for b in balls)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('baseline'); ap.add_argument('--part', required=True); ap.add_argument('--package', required=True)
ap.add_argument('--ep', default=None, help='exposed-pad number (default: the largest pad)')
ap.add_argument('--out', default=None)
a = ap.parse_args()
text = open(a.baseline).read()
root = parse(text); ps = pads(root)
ep = ep_pad(ps, a.ep)
vias = via_grid(ep)
balls_plain = [b for p in ps for b in ball_grid(p, vias if p is ep else (), p is ep)]
balls_hv = balls_plain
out = a.out or os.path.dirname(os.path.abspath(a.baseline))
base = os.path.splitext(os.path.basename(a.baseline))[0]
head, tail = strip_footer(text)
def write(suffix, body):
path = os.path.join(out, '%s%s.kicad_mod' % (base, suffix))
open(path, 'w').write(body); return os.path.basename(path)
files = {}
files['-HV'] = write('-HV', rename(head, '-HV') + via_nodes(vias, ep['num']) + tail)
files['-SB'] = write('-SB', rename(head, '-SB') + ball_nodes(balls_plain) + tail)
files['-RR'] = write('-RR', with_roundrect(rename(text, '-RR')))
rr_head, rr_tail = strip_footer(with_roundrect(rename(text, '-HV-SB-RR')))
files['-HV-SB-RR'] = write('-HV-SB-RR', rr_head + via_nodes(vias, ep['num']) + ball_nodes(balls_hv) + rr_tail)
via_rule = '%g mm pitch, %g mm drill, %g mm annular diameter, %g mm inset from the pad edge; paste balls are kept %g mm off every via centre' % (VIA_PITCH, VIA_DRILL, VIA_OD, VIA_INSET, BALL_VIA_CLEAR)
json.dump({'count': len(vias), 'exposed_pad': {'number': ep['num'], 'x': ep['x'], 'y': ep['y'], 'w': ep['w'], 'h': ep['h']},
'footprint': base, 'pitch': VIA_PITCH, 'inset_from_pad_edge': VIA_INSET, 'units': 'mm',
'origin': 'footprint origin; KiCad Y-down', 'rule': 'adom-footprint: ' + via_rule,
'via': {'annularDiameter': VIA_OD, 'drill': VIA_DRILL, 'net': 'same as pad ' + ep['num'], 'plated': True, 'ringWidth': (VIA_OD - VIA_DRILL) / 2},
'vias': vias}, open(os.path.join(out, base + '.heatsink-vias.json'), 'w'), indent=2)
json.dump({'ball': {'diameter': BALL_D, 'pitch': BALL_PITCH, 'shape': 'hemisphere, flat face on the pad', 'layer': 'User.9 (Adom.SolderBalls)'},
'count': len(balls_plain), 'layer': 'User.9', 'layerName': 'Adom.SolderBalls', 'rule': 'john/adom-solder-ball-layer-standard: O0.30 mm at 0.25 mm pitch (intentional overlap) on every paste aperture, 0.55 mm keep-out around heatsink vias, a ring around each plated through-hole pad; the EP gets a perimeter ring plus a ring around every via position', 'balls': balls_plain}, open(os.path.join(out, base + '-SB.solder-balls.json'), 'w'), indent=2)
manifest = {
'part': a.part, 'package': a.package, 'generator': 'footprint-widgets/tools/make-variants.py (adom-footprint 1.0.22 heatsink / solderballs rules)',
'variants': [
{'suffix': '', 'device': 'baseline', 'kicad_mod': os.path.basename(a.baseline), 'footprintName': base,
'changed': 'nothing; the page footprint as-is (%d SMD pads)' % len(ps)},
{'suffix': '-HV', 'device': 'heatsink vias', 'kicad_mod': files['-HV'], 'footprintName': base + '-HV', 'viaPadNumber': ep['num'],
'changed': '+%d thru_hole pads under pad %s (%g mm pitch, %g mm drill, %g mm OD, %g mm inset), on copper + mask layers only (*.Cu *.Mask)' % (len(vias), ep['num'], VIA_PITCH, VIA_DRILL, VIA_OD, VIA_INSET)},
{'suffix': '-SB', 'device': 'solder balls', 'kicad_mod': files['-SB'], 'footprintName': base + '-SB',
'changed': '+%d filled fp_circle O%.2f mm on KiCad layer User.9 (Adom.SolderBalls), one per 300um jet-paste ball, %g mm pitch; copper untouched' % (len(balls_plain), BALL_D, BALL_PITCH)},
{'suffix': '-RR', 'device': 'rounded pads', 'kicad_mod': files['-RR'], 'footprintName': base + '-RR',
'changed': 'every SMD pad shape -> KiCad roundrect: signal pads roundrect_rratio %g (the KiCad/IPC default), EP %g; positions and sizes unchanged' % (RR_SIGNAL, RR_EP)},
{'suffix': '-HV-SB-RR', 'device': 'heatsink vias + solder balls + rounded pads', 'kicad_mod': files['-HV-SB-RR'], 'footprintName': base + '-HV-SB-RR',
'changed': '= -RR pads + the %d heatsink vias as thru_hole pads on %s + the %d-ball Adom.SolderBalls layer (balls kept %g mm off every via centre)' % (len(vias), ep['num'], len(balls_hv), BALL_VIA_CLEAR)},
]}
json.dump(manifest, open(os.path.join(out, base + '.variants.json'), 'w'), indent=2)
print(json.dumps({'ep': ep['num'], 'vias': len(vias), 'balls': len(balls_plain), 'balls_with_vias': len(balls_hv), 'files': list(files.values()) + [base + '.variants.json']}, indent=1))
if __name__ == '__main__':
main()