master
John Lauer Add adom-verified, adom-models and adom-footprint-variants (moved from john/ pages) with their tools 7acd92d 16d ago
#!/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()