R reuter /

QFP58

CC BY-SA 4.0 Native

Vibe engineered circular polarized PCB antenna, inspired by the Triple Feed Patch.

Rendered
128 files · 3.7 MB at 05eecdc Download ZIP
.dockerignore 72 B
.gitignore 170 B
CLAUDE.md 11.0 KB
Dockerfile 3.2 KB
fp-lib-table 152 B
QFP58-feed-RHCP-drc.rpt 553 B
README.md 15.5 KB
run.sh 4.2 KB
uv.lock 189 KB
docs/
preview_LHCP.png 195 KB
preview_RHCP.png 195 KB
docs/svg/
QFP58-feed-LHCP-B_Cu.svg 134 KB
QFP58-feed-LHCP-Edge_Cuts.svg 11.5 KB
QFP58-feed-LHCP-F_Cu.svg 61.9 KB
QFP58-feed-RHCP-B_Cu.svg 131 KB
QFP58-feed-RHCP-Edge_Cuts.svg 11.6 KB
QFP58-feed-RHCP-F_Cu.svg 61.9 KB
QFP58-patch-B_Cu.svg 37.2 KB
QFP58-patch-Edge_Cuts.svg 11.9 KB
QFP58-patch-F_Cu.svg 16.3 KB
gerbers/
QFP58-feed-LHCP-fab.zip 31.5 KB
QFP58-feed-RHCP-fab.zip 31.6 KB
QFP58-patch-d23.06-fab.zip 12.0 KB
QFP58-patch-d23.36-fab.zip 12.0 KB
QFP58-patch-fab.zip 11.7 KB
gerbers/QFP58-feed-LHCP/
QFP58-feed-LHCP-B_Cu.gbl 4.2 KB
QFP58-feed-LHCP-B_Mask.gbs 1.6 KB
QFP58-feed-LHCP-B_Silkscreen.gbo 41.5 KB
QFP58-feed-LHCP-Edge_Cuts.gm1 17.0 KB
QFP58-feed-LHCP-F_Cu.gtl 47.5 KB
QFP58-feed-LHCP-F_Mask.gts 1.2 KB
QFP58-feed-LHCP-F_Silkscreen.gto 1.2 KB
QFP58-feed-LHCP-job.gbrjob 2.4 KB
gerbers/QFP58-feed-RHCP/
QFP58-feed-RHCP-B_Cu.gbl 4.2 KB
QFP58-feed-RHCP-B_Mask.gbs 1.6 KB
QFP58-feed-RHCP-B_Silkscreen.gbo 40.6 KB
QFP58-feed-RHCP-Edge_Cuts.gm1 17.0 KB
QFP58-feed-RHCP-F_Cu.gtl 47.5 KB
QFP58-feed-RHCP-F_Mask.gts 1.2 KB
QFP58-feed-RHCP-F_Silkscreen.gto 1.2 KB
QFP58-feed-RHCP-job.gbrjob 2.4 KB
gerbers/QFP58-patch/
QFP58-patch-B_Cu.gbl 21.0 KB
QFP58-patch-B_Mask.gbs 653 B
QFP58-patch-B_Silkscreen.gbo 663 B
QFP58-patch-Edge_Cuts.gm1 640 B
QFP58-patch-F_Cu.gtl 937 B
QFP58-patch-F_Mask.gts 810 B
QFP58-patch-F_Silkscreen.gto 9.4 KB
QFP58-patch-job.gbrjob 2.4 KB
gerbers/QFP58-patch-d23.06/
QFP58-patch-d23.06-B_Cu.gbl 21.0 KB
QFP58-patch-d23.06-B_Mask.gbs 660 B
QFP58-patch-d23.06-B_Silkscreen.gbo 670 B
QFP58-patch-d23.06-Edge_Cuts.gm1 647 B
QFP58-patch-d23.06-F_Cu.gtl 944 B
QFP58-patch-d23.06-F_Mask.gts 817 B
QFP58-patch-d23.06-F_Silkscreen.gto 10.5 KB
QFP58-patch-d23.06-job.gbrjob 2.4 KB
gerbers/QFP58-patch-d23.36/
QFP58-patch-d23.36-B_Cu.gbl 21.1 KB
QFP58-patch-d23.36-B_Mask.gbs 660 B
QFP58-patch-d23.36-B_Silkscreen.gbo 670 B
QFP58-patch-d23.36-Edge_Cuts.gm1 647 B
QFP58-patch-d23.36-F_Cu.gtl 944 B
QFP58-patch-d23.36-F_Mask.gts 817 B
QFP58-patch-d23.36-F_Silkscreen.gto 10.1 KB
QFP58-patch-d23.36-job.gbrjob 2.4 KB
kicad/QFP58-feed-LHCP/
fp-lib-table 149 B
QFP58-feed-LHCP-drc.rpt 553 B
kicad/QFP58-patch/
fp-lib-table 149 B
QFP58-patch-drc.rpt 576 B
kicad/QFP58-patch-d23.06/
fp-lib-table 149 B
QFP58-patch-d23.06-drc.rpt 528 B
kicad/QFP58-patch-d23.36/
fp-lib-table 149 B
QFP58-patch-d23.36-drc.rpt 528 B
kicad/QFP58.pretty/
CouplingDisk_3.6mm.kicad_mod 1.2 KB
FeedPin_1.1mm.kicad_mod 1.0 KB
MountingHole_2.2mm_M2.kicad_mod 1.0 KB
R_0603_Wilkinson.kicad_mod 1.2 KB
SMA_EdgeLaunch_1.0mm_generic.kicad_mod 1.7 KB
sim/full/
full_cuts_5.65GHz.png 86.6 KB
full_cuts_5.80GHz.png 87.0 KB
full_cuts_5.95GHz.png 88.6 KB
full_farfield_5.65GHz.npz 147 KB
full_farfield_5.80GHz.npz 147 KB
full_farfield_5.95GHz.npz 147 KB
full_pattern_5.65GHz.png 79.2 KB
full_pattern_5.80GHz.png 80.5 KB
full_pattern_5.95GHz.png 83.6 KB
full_pattern3d_5.65GHz.png 154 KB
full_pattern3d_5.80GHz.png 156 KB
full_pattern3d_5.95GHz.png 155 KB
full_sparams.png 30.3 KB
summary.json 1.5 KB
sim/network/
network_sparams.png 45.0 KB
sim/patch/
patch_ideal_feed_pattern_5.65GHz.png 69.2 KB
patch_ideal_feed_pattern_5.80GHz.png 69.5 KB
patch_ideal_feed_pattern_5.95GHz.png 70.5 KB
patch_sparams.png 48.0 KB
summary.json 1.4 KB
src/
generate.py 1.9 KB
qfp_geom.py 11.6 KB
qfp_sim.py 27.4 KB
src/kicad_build.py
#!/usr/bin/env python3
"""
Build native KiCad boards for QFP-58 from the qfp_geom primitives (runs on KiCad 7-10; saves in that version's format).
  feed board : B.Cu network as real tracks/arcs (net RF), F.Cu GND zone, 0603 resistors, edge SMA, pins, M2 holes
  patch board: B.Cu patch zone, top coupling-disk pads, plated pin holes; one per entry of patch_boards()
"""
import os, sys, math
import numpy as np
import pcbnew
from pcbnew import FromMM, VECTOR2I
from qfp_geom import P, build, u
from qfp_layers import outline_feed

X0, Y0 = 150.0, 100.0           # board centre on the KiCad page (mm)
REV = 'C'

def V(p):                        # top-view mm (y up) -> KiCad nm (y down)
    return VECTOR2I(FromMM(X0 + float(p[0])), FromMM(Y0 - float(p[1])))

def kdeg(a):                     # math angle (rad, CCW, y up) -> KiCad orientation (deg, CCW on screen)
    return math.degrees(a)


def new_board(path, name):
    b = pcbnew.NewBoard(path)
    ds = b.GetDesignSettings()
    ds.SetBoardThickness(FromMM(P['sub_h']))
    ds.m_CopperEdgeClearance = FromMM(0.2)
    ds.m_MinClearance = FromMM(0.15)
    b.SetCopperLayerCount(2)
    tb = b.GetTitleBlock()
    tb.SetTitle(f'QFP-58 {name}'); tb.SetRevision(REV)
    tb.SetComment(0, '5.8 GHz quad-feed sequential-rotation CP patch. 1.0 mm FR4, 1 oz, 2 layers.')
    tb.SetComment(1, 'Concept after the Triple Feed Patch (M. Baert, R. Theunis, CC BY-SA 4.0).')
    return b

def net(b, name):
    n = pcbnew.NETINFO_ITEM(b, name); b.Add(n); return n

def poly_shape(b, pts, layer, width=0.1, filled=False):
    s = pcbnew.PCB_SHAPE(b)
    s.SetShape(pcbnew.SHAPE_T_POLY)
    s.SetPolyPoints([V(p) for p in pts])
    s.SetLayer(layer); s.SetWidth(FromMM(width)); s.SetFilled(filled)
    b.Add(s); return s

def circle_shape(b, c, r, layer, width=0.1, filled=False):
    s = pcbnew.PCB_SHAPE(b)
    s.SetShape(pcbnew.SHAPE_T_CIRCLE)
    s.SetCenter(V(c)); s.SetEnd(V((c[0] + r, c[1])))
    s.SetLayer(layer); s.SetWidth(FromMM(width)); s.SetFilled(filled)
    b.Add(s); return s

def text(b, s, pos, layer, h=0.8, mirror=False):
    t = pcbnew.PCB_TEXT(b)
    t.SetText(s); t.SetPosition(V(pos)); t.SetLayer(layer)
    t.SetTextSize(VECTOR2I(FromMM(h), FromMM(h))); t.SetTextThickness(FromMM(h*0.15))
    t.SetMirrored(mirror)
    b.Add(t); return t

def zone(b, pts, layer, n, clearance, name, priority=0):
    z = pcbnew.ZONE(b)
    z.SetLayer(layer); z.SetNet(n); z.SetZoneName(name)
    o = z.Outline(); o.NewOutline()
    for p in pts:
        q = V(p); o.Append(q.x, q.y)
    z.SetLocalClearance(FromMM(clearance))
    z.SetMinThickness(FromMM(0.2))
    z.SetPadConnection(pcbnew.ZONE_CONNECTION_FULL)
    z.SetAssignedPriority(priority)
    z.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_NEVER)
    b.Add(z); return z


# ----------------------------------------------------------------------------- footprints
def fp_new(b, ref, value, lib_name):
    fp = pcbnew.FOOTPRINT(b)
    fp.SetFPID(pcbnew.LIB_ID('QFP58', lib_name))
    fp.SetReference(ref); fp.SetValue(value)
    fp.Reference().SetTextSize(VECTOR2I(FromMM(0.8), FromMM(0.8)))
    fp.Reference().SetTextThickness(FromMM(0.12))
    fp.Value().SetLayer(pcbnew.F_Fab)
    return fp

def pad_smd(fp, num, pos, size, shape=pcbnew.PAD_SHAPE_RECT, n=None, layers='F'):
    p = pcbnew.PAD(fp)
    p.SetNumber(num); p.SetAttribute(pcbnew.PAD_ATTRIB_SMD); p.SetShape(shape)
    p.SetSize(VECTOR2I(FromMM(size[0]), FromMM(size[1])))
    ls = pcbnew.LSET()
    for L in ((pcbnew.F_Cu, pcbnew.F_Mask) if layers == 'F' else (pcbnew.B_Cu, pcbnew.B_Mask)):
        ls.AddLayer(L)
    p.SetLayerSet(ls)
    q = VECTOR2I(FromMM(pos[0]), FromMM(pos[1]))
    if hasattr(p, 'SetPos0'): p.SetPos0(q)      # KiCad 7 only
    p.SetPosition(q)
    if n: p.SetNet(n)
    fp.Add(p); return p

def pad_tht(fp, num, pos, size, drill, n=None, clearance=None, plated=True):
    p = pcbnew.PAD(fp)
    p.SetNumber(num)
    p.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
    p.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
    p.SetSize(VECTOR2I(FromMM(size), FromMM(size)))
    p.SetDrillSize(VECTOR2I(FromMM(drill), FromMM(drill)))
    p.SetLayerSet(p.PTHMask() if plated else p.UnplatedHoleMask())
    q = VECTOR2I(FromMM(pos[0]), FromMM(pos[1]))
    if hasattr(p, 'SetPos0'): p.SetPos0(q)      # KiCad 7 only
    p.SetPosition(q)
    if clearance is not None: p.SetLocalClearance(FromMM(clearance))
    if n: p.SetNet(n)
    fp.Add(p); return p

def place(b, fp, pos, ang, back=False):
    """footprint built at origin, x-axis = local axis; place with local x-axis along math angle `ang`."""
    b.Add(fp)
    if back:
        # mirror top<->bottom (y -> -y), layers F<->B; KiCad 9+ takes a FLIP_DIRECTION instead of a bool
        fp.Flip(VECTOR2I(0, 0), getattr(pcbnew, 'FLIP_DIRECTION_TOP_BOTTOM', False))
    fp.SetOrientationDegrees(round(kdeg(ang), 1))
    fp.SetPosition(V(pos))
    return fp

def check_pads(fp, expected, tol=0.01):
    got = sorted([(pcbnew.ToMM(p.GetPosition().x), pcbnew.ToMM(p.GetPosition().y)) for p in fp.Pads()])
    exp = sorted([(X0 + e[0], Y0 - e[1]) for e in expected])
    for g, e in zip(got, exp):
        assert abs(g[0]-e[0]) < tol and abs(g[1]-e[1]) < tol, f'{fp.GetReference()}: pad {g} != {e}'


# ----------------------------------------------------------------------------- footprint builders
def fp_resistor(b, ref='R', val='R', n=None):
    d = P['res_pitch']/2
    fp = fp_new(b, ref, val, 'R_0603_Wilkinson')
    for k, num in ((-1, '1'), (1, '2')):
        pad_smd(fp, num, (k*d, 0), P['res_pad'], n=n)
    fp.Reference().SetPosition(VECTOR2I(0, FromMM(-1.3)))
    return fp

def fp_feedpin(b, ref='P', val='feed pin', n=None):
    fp = fp_new(b, ref, val, 'FeedPin_1.1mm')
    pad_tht(fp, '1', (0, 0), P['pin_pad'], P['pin_drill'], n=n, clearance=(P['gnd_antipad'] - P['pin_pad'])/2)
    fp.Reference().SetVisible(False)
    return fp

def fp_sma(b, w_in, gx, ref='J1', n_sig=None, n_gnd=None):
    fp = fp_new(b, ref, 'SMA edge-launch, 1.0 mm PCB', 'SMA_EdgeLaunch_1.0mm_generic')
    pad_smd(fp, '1', (0, 0), (P['sma_sig_len'], w_in), n=n_sig)
    for side in ('F', 'B'):
        for k in (-1, 1):
            pad_smd(fp, '2', (gx, k*P['sma_gnd_off']), (P['sma_gnd_len'], P['sma_gnd_w']), n=n_gnd, layers=side)
    fp.Reference().SetPosition(VECTOR2I(FromMM(-3.0), 0))
    return fp

def fp_mhole(b, ref='H'):
    fp = fp_new(b, ref, 'M2 NPTH', 'MountingHole_2.2mm_M2')
    pad_tht(fp, '', (0, 0), P['mh_drill'], P['mh_drill'], clearance=(P['mh_clear'] - P['mh_drill'])/2, plated=False)
    fp.Reference().SetVisible(False)
    return fp

def fp_disk(b, ref='C', n=None):
    fp = fp_new(b, ref, 'coupling disk + pin', 'CouplingDisk_3.6mm')
    pad_tht(fp, '1', (0, 0), P['patch_pin_ring'], P['pin_drill'], n=n,
            clearance=(P['patch_antipad'] - P['patch_pin_ring'])/2)
    pad_smd(fp, '1', (0, 0), (P['disk_d'], P['disk_d']), shape=pcbnew.PAD_SHAPE_CIRCLE, n=n)
    fp.Reference().SetVisible(False)
    return fp

def write_library(libdir, w_in, gx):
    import shutil
    if os.path.isdir(libdir): shutil.rmtree(libdir)
    # a .pretty library is just a directory; KiCad 10's FootprintLibCreate/CreateLibrary bindings are broken
    os.makedirs(libdir)
    if hasattr(pcbnew, 'PCB_IO_MGR'):   # KiCad 8+
        io = pcbnew.PCB_IO_MGR.FindPlugin(pcbnew.PCB_IO_MGR.KICAD_SEXP)
    else:                               # KiCad 7
        io = pcbnew.IO_MGR.PluginFind(pcbnew.IO_MGR.KICAD_SEXP)
    b = pcbnew.BOARD()
    for fp in (fp_resistor(b, 'R', '0603'), fp_feedpin(b), fp_sma(b, w_in, gx), fp_mhole(b), fp_disk(b)):
        io.FootprintSave(libdir, fp)

def write_lib_table(projdir, libdir):
    rel = os.path.relpath(libdir, projdir)
    open(os.path.join(projdir, 'fp-lib-table'), 'w').write(
        '(fp_lib_table\n  (version 7)\n  (lib (name "QFP58")(type "KiCad")(uri "${KIPRJMOD}/' + rel + '")'
        '(options "")(descr "QFP-58 antenna footprints"))\n)\n')


# ----------------------------------------------------------------------------- project layout
SHOWCASE = 'QFP58-feed-RHCP'     # the one board in the repo root (the PCB-hosting repo renders a single root PCB)

def board_path(out, name):
    """Showcase board in the root, every other board in kicad/<name>/ (one PCB per folder)."""
    d = out if name == SHOWCASE else os.path.join(out, 'kicad', name)
    return os.path.abspath(os.path.join(d, f'{name}.kicad_pcb'))

def lib_path(out):
    return os.path.abspath(os.path.join(out, 'kicad', 'QFP58.pretty'))

def board_names():
    return ['QFP58-feed-RHCP', 'QFP58-feed-LHCP'] + [name for _, name in patch_boards()]


# ----------------------------------------------------------------------------- feed board
def feed_board(path, hand):
    f, info = build(hand)
    b = new_board(path, f'feed board {hand}')
    RF, GND = net(b, 'RF'), net(b, 'GND')

    # outline
    outline = outline_feed(f)
    poly_shape(b, list(outline.exterior.coords)[:-1], pcbnew.Edge_Cuts, 0.05)

    # network: native tracks + arcs
    for p in f['prims']:
        if p['kind'] == 'seg':
            t = pcbnew.PCB_TRACK(b); t.SetStart(V(p['p0'])); t.SetEnd(V(p['p1']))
        else:
            t = pcbnew.PCB_ARC(b)
            am = (p['a0'] + p['a1'])/2
            t.SetStart(V(p['c'] + p['r']*u(p['a0']))); t.SetMid(V(p['c'] + p['r']*u(am)))
            t.SetEnd(V(p['c'] + p['r']*u(p['a1'])))
        t.SetWidth(FromMM(p['w'])); t.SetLayer(pcbnew.B_Cu); t.SetNet(RF)
        b.Add(t)

    # isolation resistors (bottom side)
    d = P['res_pitch']/2
    for ref, c, a, val in f['res']:
        fp = fp_resistor(b, ref, f'{val}R 0603 1%', RF)
        place(b, fp, c, a, back=True)
        check_pads(fp, [c + k*d*u(a) for k in (-1, 1)])

    # feed pins (THT, 1.0 mm Cu wire): ground antipad from pad clearance
    labels = ['0°', '−90°', '−180°', '−270°']
    for k, p in enumerate(f['pins']):
        fp = fp_feedpin(b, f'P{k+1}', f'feed pin {labels[k]}', RF)
        place(b, fp, p, 0.0, back=True)

    # edge SMA J1: local x-axis points to the board edge
    s = f['sma']
    ang = math.atan2(s['u'][1], s['u'][0])
    w_in = f['prims'][-1]['w']
    gx = float(np.dot(s['gnd_c'][0] - s['sig_c'], s['u']))
    fp = fp_sma(b, w_in, gx, 'J1', RF, GND)                               # signal pad -> B.Cu after flip
    f['_sma_lib'] = (w_in, gx)
    place(b, fp, s['sig_c'], ang, back=True)

    # stitching vias for the SMA ground legs
    for v in f['vias']:
        via = pcbnew.PCB_VIA(b); via.SetPosition(V(v)); via.SetViaType(pcbnew.VIATYPE_THROUGH)
        via.SetDrill(FromMM(P['via_drill'])); via.SetWidth(FromMM(P['via_pad'])); via.SetNet(GND)
        via.SetLayerPair(pcbnew.F_Cu, pcbnew.B_Cu)
        b.Add(via)

    # M2 mounting holes (NPTH)
    for k, p in enumerate(f['mh']):
        fp = fp_mhole(b, f'H{k+1}')
        place(b, fp, p, 0.0)

    # F.Cu ground plane (faces the patch)
    plane = outline.buffer(-P['edge_pullback'])                   # as in qfp_layers/qfp_sim; keeps the fill off the edge
    zone(b, list(plane.exterior.coords)[:-1], pcbnew.F_Cu, GND, 0.4, 'GND_plane')

    # bottom silkscreen notes (mirrored, bottom side)
    ty = 21.0 if hand == 'RHCP' else -21.0
    rv = {r[0]: r[3] for r in f['res']}
    lines = [f'QFP-58  {hand}  5.8 GHz  rev {REV}', 'FR4 1.0 mm  |  gap 4.00 mm Cu-Cu',
             f"R1 {rv['R1']}R   R2,R3 {rv['R2']}R   0603 1%"]
    for i, ln in enumerate(lines):
        text(b, ln, (0, ty + 1.6 - 1.6*i), pcbnew.B_SilkS, 0.9, mirror=True)

    return b, f, info


# ----------------------------------------------------------------------------- patch board
def patch_board(path):
    b = new_board(path, 'patch board')
    PATCH = net(b, 'PATCH')
    a = P['patch_d']/2
    circle_shape(b, (0, 0), a + P['patch_margin'], pcbnew.Edge_Cuts, 0.05)
    circ = [(a*math.cos(t), a*math.sin(t)) for t in np.linspace(0, 2*math.pi, 256, endpoint=False)]
    zone(b, circ, pcbnew.B_Cu, PATCH, (P['patch_antipad'] - P['patch_pin_ring'])/2, 'patch')
    for k in range(4):
        p = P['rp']*u(k*math.pi/2)
        n = net(b, f'C{k+1}')
        fp = fp_disk(b, f'C{k+1}', n)
        place(b, fp, p, 0.0)
    text(b, f"QFP-58  d{P['patch_d']:.2f}  TOP", (0, -8.2), pcbnew.F_SilkS, 0.8)
    return b


def patch_boards():
    """(patch_d, board name): the design value plus the bracketing variants, which carry d in the name."""
    return [(P['patch_d'], 'QFP58-patch')] + [(d, f'QFP58-patch-d{d:.2f}') for d in P['patch_variants']]


def fill_save_drc(b, path, libdir, ignore=()):
    import json
    pcbnew.ZONE_FILLER(b).Fill(b.Zones())
    pcbnew.SaveBoard(path, b)
    write_lib_table(os.path.dirname(path), libdir)
    pro = path.replace('.kicad_pcb', '.kicad_pro')
    j = json.load(open(pro))
    for k in ignore:
        j['board']['design_settings']['rule_severities'][k] = 'ignore'
    json.dump(j, open(pro, 'w'), indent=2)
    rpt = path.replace('.kicad_pcb', '-drc.rpt')
    import shutil, subprocess
    if shutil.which('kicad-cli'):   # KiCad 8+; WriteDRCReport aborts outside the GUI on KiCad 10
        subprocess.run(['kicad-cli', 'pcb', 'drc', '--all-track-errors', '--units', 'mm', '-o', rpt, path],
                       check=True, capture_output=True)
    else:
        b2 = pcbnew.LoadBoard(path)
        pcbnew.WriteDRCReport(b2, rpt, pcbnew.EDA_UNITS_MILLIMETRES, True)
    return rpt


if __name__ == '__main__':
    out = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    lib = lib_path(out)
    for name in board_names():
        os.makedirs(os.path.dirname(board_path(out, name)), exist_ok=True)
    write_library(lib, 1.883, 0.0)   # placeholder, rewritten below
    for hand in ('RHCP', 'LHCP'):
        pth = board_path(out, f'QFP58-feed-{hand}')
        b, f, info = feed_board(pth, hand)
        if hand == 'RHCP':
            write_library(lib, *f['_sma_lib'])
        print(hand, '->', fill_save_drc(b, pth, lib))
    d0 = P['patch_d']
    for d, name in patch_boards():
        P['patch_d'] = d
        pth = board_path(out, name)
        print(f'patch d{d:.2f} ->', fill_save_drc(patch_board(pth), pth, lib, ignore=('isolated_copper',)))
    P['patch_d'] = d0