main
noah amfm-receiver-molecule: KiCad project, fab files, 3D and README (6/7) 53707c4 1d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791
"""Build, place and prepare the 4-layer PCB from design.DESIGN.

Usage:  python3 gen_pcb.py place     -> builds board, runs placement, writes .kicad_pcb + .dsn
        python3 gen_pcb.py finish    -> imports Freerouting .ses, adds pours, fills zones, saves
"""
import os, sys, json, math, time
import pcbnew
from design import DESIGN, POWER_NETS, MOLECULE_PITCH, MOLECULE_PINS, EDGE_CONTACTS, PROBE_CONTACTS, TEST_POINTS
from gen_sch import sheet_uuid, file_uuid, PROJECT
from schgen import uid
from placer import Item, Placer, rot_pt
from check_sch import export_netlist
from sexp import find, findall
import pcbgeom
import floorplan

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.abspath(os.path.join(HERE, '..'))
BOARD = os.path.join(OUT, PROJECT + '.kicad_pcb')
DSN = os.path.join(OUT, 'build', PROJECT + '.dsn')
SES = os.path.join(OUT, 'build', PROJECT + '.ses')
PLACEMENT_JSON = os.path.join(OUT, 'build', 'placement.json')
PLACED = os.path.join(OUT, 'build', PROJECT + '_placed.kicad_pcb')

# Adom Molecule geometry: large corner machine pins 96 x 96 mm apart.  The outline sits 3.25 mm beyond the pin
# centres (the 5.2 mm pin pad + 0.65 mm, the same edge clearance as Adom's example molecules), corner arcs on the pins.
MARGIN = 3.25
W, H = MOLECULE_PITCH[0] + 2 * MARGIN, MOLECULE_PITCH[1] + 2 * MARGIN     # 102.5 x 102.5 mm
OX, OY = 100.0 - MARGIN, 100.0 - MARGIN     # board top-left in KiCad coordinates (MP1 lands on 100, 100)
GRID = 2.0                  # machine pin / contact grid, measured from MP1


def grid_local(gx, gy):
    return MARGIN + gx, MARGIN + gy
FPLIBS = {'AMFM': os.path.join(OUT, 'lib', 'AMFM.pretty'), 'AdomMachine': os.path.join(OUT, 'lib', 'AdomMachine.pretty')}

# Floorplan: see floorplan.py (one outlined, labelled rectangle per functional block)
# Fixed parts: (x, y, rotation) board-local; x/y = footprint origin
FIXED = {
    'J101': (3.4, MARGIN + 16.0, 270),          # USB-C, opening faces the left edge (between MC2 and MC3)
    'J301': (W - 4.05, MARGIN + 12.5, 270),     # BWSMA-KWE right-angle SMA, barrel over the right edge
    'D201': (10.4, 83.4, 0), 'D202': (14.2, 83.4, 0), 'D203': (18.0, 83.4, 0),   # LEDs, in the LED / audio-out block (bottom left)
}
for _ref, _net, _gx, _gy in MOLECULE_PINS:
    FIXED[_ref] = grid_local(_gx, _gy) + (0,)
for _ref, _net, _label, _gx, _gy in EDGE_CONTACTS:
    FIXED[_ref] = grid_local(_gx, _gy) + (0,)
CONTACT_LABELS = {r: l for r, n, l, gx, gy in EDGE_CONTACTS}
CONTACT_LABELS.update({r: l for r, n, l, reg in PROBE_CONTACTS})
CONTACT_LABELS.update({r: l for tps in TEST_POINTS.values() for r, n, l in tps})
LABELLED = ('MC', 'TP')       # machine contacts and test points carry their signal name on the silkscreen


def contact_side(ref):
    """Which way a contact's silk label points (away from its board edge)."""
    x, y, _ = FIXED.get(ref, (W / 2, H / 2, 0))
    gx, gy = x - MARGIN, y - MARGIN          # grid coordinates from MP1
    if gx < 1:
        return 'R'
    if gx > MOLECULE_PITCH[0] - 1:
        return 'L'
    if gy > MOLECULE_PITCH[1] - 1:
        return 'U'
    if gy < 1:
        return 'D'
    return None

NETCLASSES = {
    # name: (track, clearance, via_dia, via_drill)
    'Default': (0.20, 0.15, 0.60, 0.30),
    'Power':   (0.40, 0.18, 0.60, 0.30),
    'RF':      (0.36, 0.25, 0.60, 0.30),
}
POWER_CLASS = ['+5V', 'VBUS', '+5VA', '+3V3', '+3.3VA', 'GND', '+5V_FM', '+5V_AM', 'V_IF1', 'V_IF2',
               'V_IF3', 'V_IF4', 'V_RF1', 'V_LO1', 'V_AM1', 'V_AM2']
RF_CLASS = ['ANT', 'AMLPF_1', 'AMLPF_2', 'AM_RFIN', 'FMHP_1', 'FMHP_2', 'FM_BPF']
POWER_CLASS += ['VIN', 'VIN_OR']

# Nets carried by planes -> ignored by the placer's wire-length model
PLANE_NETS = {'GND', '+5VA', '+3V3'}

# Decoupling capacitors pulled next to the pin they serve: (cap, cap pad, ic, ic pad)
DECOUPLE = [
    ('C102', '1', 'U102', '1'), ('C103', '1', 'U102', '1'), ('C104', '1', 'U102', '5'), ('C105', '1', 'U102', '5'),
    ('C106', '1', 'FB101', '2'), ('C107', '1', 'FB101', '2'), ('C108', '1', 'U103', '5'), ('C109', '1', 'U103', '5'),
    ('C110', '1', 'U103', '4'), ('C111', '1', 'U103', '6'), ('C114', '1', 'U103', '6'),
    ('C116', '1', 'U103', '8'), ('C117', '1', 'U103', '9'), ('Y101', '1', 'U103', '8'),
    ('C205', '1', 'U201', '8'), ('C211', '1', 'U202', '8'), ('C620', '1', 'U601', '8'),
    ('C721', '1', 'U701', '8'), ('C101', '1', 'F101', '2'), ('FB102', '1', 'U102', '5'),
]


def mm(v):
    return pcbnew.FromMM(v)


def vec(x, y):
    return pcbnew.VECTOR2I(mm(OX + x), mm(OY + y))


_fp_cache = {}


def load_fp(fpid):
    lib, name = fpid.split(':')
    path = FPLIBS.get(lib, '/usr/share/kicad/footprints/%s.pretty' % lib)
    fp = pcbnew.FootprintLoad(path, name)
    if fp is None:
        raise RuntimeError('footprint not found: ' + fpid)
    fp.SetFPID(pcbnew.LIB_ID(lib, name))
    return fp


def courtyard_box(fp):
    cy = fp.GetCourtyard(pcbnew.F_CrtYd)
    if cy.OutlineCount():
        bb = cy.BBox()
        return (pcbnew.ToMM(bb.GetLeft()), pcbnew.ToMM(bb.GetTop()), pcbnew.ToMM(bb.GetRight()),
                pcbnew.ToMM(bb.GetBottom()))
    bb = fp.GetBoundingBox(False)
    return (pcbnew.ToMM(bb.GetLeft()), pcbnew.ToMM(bb.GetTop()), pcbnew.ToMM(bb.GetRight()),
            pcbnew.ToMM(bb.GetBottom()))


def symbol_path(part):
    su = uid(file_uuid(part.sheet), 'sym', part.ref, part.units[0])
    return '/%s/%s' % (sheet_uuid(part.sheet), su)



_PIN_NET = None


def kicad_pin_nets():
    """(ref, pin) -> net name exactly as the schematic's netlist names it (keeps PCB/schematic parity)."""
    global _PIN_NET
    if _PIN_NET is None:
        n = export_netlist(os.path.join(OUT, PROJECT + '.kicad_sch'))
        _PIN_NET = {}
        for net in findall(find(n, 'nets'), 'net'):
            name = find(net, 'name')[1]
            for nd in findall(net, 'node'):
                _PIN_NET[(find(nd, 'ref')[1], find(nd, 'pin')[1])] = name
    return _PIN_NET


def kicad_net_name(design_net):
    """Map a design.py net name to the schematic net name."""
    for p in DESIGN.parts:
        for pin, n in p.pins.items():
            if n == design_net:
                return kicad_pin_nets()[(p.ref, pin)]
    return design_net


def setup_rules(board):
    board.SetCopperLayerCount(4)
    ls = board.GetEnabledLayers()
    for l in (pcbnew.F_Cu, pcbnew.In1_Cu, pcbnew.In2_Cu, pcbnew.B_Cu):
        ls.AddLayer(l)
    board.SetEnabledLayers(ls)
    board.SetLayerName(pcbnew.In1_Cu, 'In1.Cu')
    board.SetLayerName(pcbnew.In2_Cu, 'In2.Cu')
    board.SetLayerType(pcbnew.In1_Cu, pcbnew.LT_POWER)
    board.SetLayerType(pcbnew.In2_Cu, pcbnew.LT_POWER)
    bds = board.GetDesignSettings()
    bds.m_MinClearance = mm(0.15)
    bds.m_TrackMinWidth = mm(0.15)
    bds.m_ViasMinSize = mm(0.5)
    bds.m_ViasMinAnnularWidth = mm(0.1)
    bds.m_MinThroughDrill = mm(0.3)
    bds.m_HoleClearance = mm(0.25)
    bds.m_HoleToHoleMin = mm(0.25)
    bds.m_CopperEdgeClearance = mm(0.25)
    bds.m_SilkClearance = mm(0.0)
    bds.m_MinSilkTextHeight = mm(0.6)
    bds.m_MinSilkTextThickness = mm(0.1)
    ns = bds.m_NetSettings
    for name, (tw, cl, vd, vdr) in NETCLASSES.items():
        nc = ns.GetDefaultNetclass() if name == 'Default' else pcbnew.NETCLASS(name)
        nc.SetTrackWidth(mm(tw))
        nc.SetClearance(mm(cl))
        nc.SetViaDiameter(mm(vd))
        nc.SetViaDrill(mm(vdr))
        if name != 'Default':
            ns.SetNetclass(name, nc)
    assign_netclasses(ns)


def assign_netclasses(ns):
    ns.ClearNetclassPatternAssignments()
    for n in POWER_CLASS:
        ns.SetNetclassPatternAssignment(kicad_net_name(n), 'Power')
    for n in RF_CLASS:
        ns.SetNetclassPatternAssignment(kicad_net_name(n), 'RF')


def add_outline(board):
    r = MARGIN                      # corner arcs are centred on the corner machine pins
    x0, y0, x1, y1 = 0.0, 0.0, W, H

    def line(a, b):
        s = pcbnew.PCB_SHAPE(board, pcbnew.SHAPE_T_SEGMENT)
        s.SetStart(vec(*a))
        s.SetEnd(vec(*b))
        s.SetLayer(pcbnew.Edge_Cuts)
        s.SetWidth(mm(0.1))
        board.Add(s)

    def arc(c, start, mid, end):
        s = pcbnew.PCB_SHAPE(board, pcbnew.SHAPE_T_ARC)
        s.SetArcGeometry(vec(*start), vec(*mid), vec(*end))
        s.SetLayer(pcbnew.Edge_Cuts)
        s.SetWidth(mm(0.1))
        board.Add(s)
    k = r * (1 - math.sqrt(0.5))
    line((x0 + r, y0), (x1 - r, y0))
    line((x1, y0 + r), (x1, y1 - r))
    line((x1 - r, y1), (x0 + r, y1))
    line((x0, y1 - r), (x0, y0 + r))
    arc(None, (x1 - r, y0), (x1 - k, y0 + k), (x1, y0 + r))
    arc(None, (x1, y1 - r), (x1 - k, y1 - k), (x1 - r, y1))
    arc(None, (x0 + r, y1), (x0 + k, y1 - k), (x0, y1 - r))
    arc(None, (x0, y0 + r), (x0 + k, y0 + k), (x0 + r, y0))


def add_zone(board, net, layer, poly, priority=0, clearance=0.25, min_w=0.2, thermal=True):
    z = pcbnew.ZONE(board)
    z.SetLayer(layer)
    z.SetNetCode(board.FindNet(net).GetNetCode())
    z.SetAssignedPriority(priority)
    z.SetLocalClearance(mm(clearance))
    z.SetMinThickness(mm(min_w))
    z.SetPadConnection(pcbnew.ZONE_CONNECTION_THT_THERMAL if thermal else pcbnew.ZONE_CONNECTION_FULL)
    z.SetThermalReliefGap(mm(0.3))
    z.SetThermalReliefSpokeWidth(mm(0.35))
    z.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_ALWAYS)
    ol = z.Outline()
    ol.NewOutline()
    for x, y in poly:
        ol.Append(mm(OX + x), mm(OY + y))
    board.Add(z)
    return z


def text(board, s, x, y, size=1.2, layer=pcbnew.F_SilkS, angle=0, bold=True):
    t = pcbnew.PCB_TEXT(board)
    t.SetText(s)
    t.SetPosition(vec(x, y))
    t.SetLayer(layer)
    t.SetTextSize(pcbnew.VECTOR2I(mm(size), mm(size)))
    t.SetTextThickness(mm(size * 0.15))
    t.SetBold(bold)
    t.SetTextAngleDegrees(angle)
    if layer == pcbnew.B_SilkS:
        t.SetMirrored(True)
    board.Add(t)
    return t


def build_board():
    board = pcbnew.NewBoard(BOARD)
    setup_rules(board)
    add_outline(board)
    pin_net = kicad_pin_nets()
    netinfo = {}
    for n in sorted(set(pin_net.values())):
        ni = pcbnew.NETINFO_ITEM(board, n)
        board.Add(ni)
        netinfo[n] = ni
    fps = {}
    for p in DESIGN.parts:
        fp = load_fp(p.footprint)
        fp.SetReference(p.ref)
        fp.SetValue(p.value)
        fp.SetPath(pcbnew.KIID_PATH(symbol_path(p)))
        sh = DESIGN.sheets[p.sheet]
        fp.SetSheetname('/%s/' % sh['name'])
        fp.SetSheetfile(sh['file'])
        for k, v in p.fields.items():
            fp.SetField(k, v)
            for fld in fp.GetFields():
                if fld.GetName() == k:
                    fld.SetVisible(False)
                    fld.SetLayer(pcbnew.F_Fab)
        if p.description:
            try:
                fp.GetField(pcbnew.FIELD_T_DESCRIPTION).SetText(p.description)
            except Exception:
                pass
        try:
            fp.GetField(pcbnew.FIELD_T_DATASHEET).SetText(p.datasheet or '')
        except Exception:
            pass
        if not p.in_bom:
            fp.SetExcludedFromBOM(True)
        for pad in fp.Pads():
            key = (p.ref, pad.GetNumber())
            if key in pin_net:
                pad.SetNet(netinfo[pin_net[key]])
            if p.ref.startswith(('MC', 'MP')):
                pad.SetLocalZoneConnection(pcbnew.ZONE_CONNECTION_FULL)   # press-fit, not soldered
            if p.ref == 'J301' and pin_net.get(key) == 'GND':
                pad.SetLocalZoneConnection(pcbnew.ZONE_CONNECTION_FULL)   # SMA shell: solid RF ground
        # silkscreen reference: small
        ref = fp.Reference()
        ref.SetTextSize(pcbnew.VECTOR2I(mm(0.7), mm(0.7)))
        ref.SetTextThickness(mm(0.12))
        fp.Value().SetVisible(False)
        board.Add(fp)
        fps[p.ref] = fp
    board.SynchronizeNetsAndNetClasses(True)
    return board, fps


def make_items(fps):
    items = []
    for p in DESIGN.parts:
        fp = fps[p.ref]
        fp.SetPosition(pcbnew.VECTOR2I(0, 0))
        fp.SetOrientationDegrees(0)
        crt = courtyard_box(fp)
        pads = []
        for pad in fp.Pads():
            n = pad.GetNetname() or None
            pos = pad.GetPosition()
            pads.append((n, pcbnew.ToMM(pos.x), pcbnew.ToMM(pos.y)))
        fixed = FIXED.get(p.ref)
        if p.ref.startswith('MC'):
            lw = len(CONTACT_LABELS.get(p.ref, '')) * 0.62 + 0.6       # label length + gap
            side = contact_side(p.ref)
            crt = {'R': (-1.0, -1.0, 1.2 + lw, 1.0), 'L': (-1.2 - lw, -1.0, 1.0, 1.0),
                   'U': (-1.0, -1.2 - lw, 1.0, 1.0), 'D': (-1.0, -1.0, 1.0, 1.2 + lw),
                   None: (-1.0, -1.0, 1.0 + lw + 0.2, 1.0)}[side]
        elif p.ref.startswith('TP'):
            r = 0.75 if p.footprint.endswith('D1.0mm') else 1.0              # pad radius + a little
            crt = (-r, -r, r + 0.35 + len(CONTACT_LABELS.get(p.ref, '')) * 0.55, r)
        elif p.ref in CHIP_NAMES:
            # reserve a strip under the chip (local +y) for its name on the silkscreen
            half = chip_label_width(p.ref) / 2 + 0.2
            crt = (min(crt[0], -half), crt[1], max(crt[2], half), crt[3] + CHIP_LABEL_STRIP)
        big = p.lib_id.startswith('MCU') or p.ref.startswith('J') or p.ref.startswith('SW')
        rots = (0,) if big else (0, 90, 180, 270)
        if p.lib_id.startswith('Amplifier'):
            rots = (0, 90, 180, 270)
        if p.ref in CHIP_NAMES and not big:
            rots = (0, 180)                  # keep the chip-name strip horizontal
        key = BLOCK_OF.get(p.ref)
        reg = floorplan.place_rect(key) if key else (0.6, 0.6, W - 0.6, H - 0.6)
        it = Item(p.ref, crt, pads, reg, rotations=(0,) if p.ref.startswith(('MC', 'MP', 'TP')) else rots,
                  fixed=fixed, margin=0.3 if p.footprint.endswith('0603_1608Metric') else 0.4)
        if fixed is None:
            it.lattice = (0.0, 0.0, 0.5)             # tidy: every part origin on a 0.5 mm grid
            if len(pads) == 2:
                it.pref = 0                          # two-terminal parts prefer to lie horizontally
        if p.footprint.split(':')[1].startswith('QFN-48'):
            it.margin = 1.2                          # fine-pitch MCU: keep a ring free for its fan-out vias
            it.rebuild()
        if p.ref.startswith('MC') and fixed is None:
            it.lattice = (MARGIN, MARGIN, GRID)      # probe contact: stay on the machine-contact grid
            it.margin = 0.6                          # keep a probe needle's worth of room around it
            it.rebuild()
        items.append(it)
    return items


def initial_grid(items):
    """Row-pack each region's movable items in design order (blocks stay together)."""
    by_reg = {}
    for it in items:
        if it.fixed is None:
            by_reg.setdefault(it.region, []).append(it)
    for reg, its in by_reg.items():
        x0, y0, x1, y1 = reg
        x, y, rowh = x0 + 0.5, y0 + 0.5, 0.0
        for it in its:
            b = it.box(0, 0, 0)
            w, h = b[2] - b[0], b[3] - b[1]
            if x + w > x1 - 0.5:
                x, y, rowh = x0 + 0.5, y + rowh, 0.0
            it.x, it.y, it.r = x - b[0], y - b[1], 0
            it.snap()
            it.update()
            x += w
            rowh = max(rowh, h)


BLOCK_OF = floorplan.block_of_parts()
# Chips that keep a silkscreen label: their part name, printed in a strip reserved under the package
CHIP_NAMES = {'U101': 'USBLC6-2SC6', 'U102': 'AP2112K-3.3', 'U103': 'STM32G0B1CCU7', 'U201': 'TLV9062',
              'U202': 'TLV9062', 'U601': 'TLV9062', 'U701': 'TLV9062'}
CHIP_LABEL_SIZE = {'U101': 0.7, 'U102': 0.7}
CHIP_LABEL_STRIP = 1.3


def chip_label_width(ref):
    return len(CHIP_NAMES[ref]) * 0.92 * CHIP_LABEL_SIZE.get(ref, 0.8)      # measured KiCad stroke-font advance


def it_region_name(it):
    return BLOCK_OF.get(it.ref)


def run_placement(fps, moves_per_item=3000, seed=7, log=print, keep=None):
    items = make_items(fps)
    for it in items:
        if keep and it.ref in keep and it.fixed is None:     # incremental: freeze parts already placed
            it.fixed = tuple(keep[it.ref])
            it.x, it.y, it.r = it.fixed
            it.update()
    for name, box in floorplan.TEXT_KEEPOUTS.items():      # fixed, pad-less stand-ins that keep parts away
        items.append(Item(name, box, [], (0, 0, W, H), rotations=(0,), fixed=(0.0, 0.0, 0), margin=0.0))
    initial_grid(items)
    weights = {}
    for n in DESIGN.nets():
        if n in RF_CLASS or n.startswith(('FMRF', 'FMLO', 'VAR_', 'AMLO', 'FM_MIX', 'AM_MIX', 'IF_MIX', 'XO_')):
            weights[n] = 2.0
    idx = {it.ref: it for it in items}
    aff = []
    for c, cp, u, up in DECOUPLE:
        ic = [i for i, pd in enumerate(idx[c].pads)]
        ci = [i for i, (n, x, y) in enumerate(idx[c].pads)
              if fps[c].Pads()[i].GetNumber() == cp][0]
        ui = [i for i, (n, x, y) in enumerate(idx[u].pads) if fps[u].Pads()[i].GetNumber() == up][0]
        aff.append((c, ci, u, ui, 3.0))
    pl = Placer(items, weights, PLANE_NETS, aff, seed=seed)
    t = time.time()
    blocks = {}
    for it in items:
        if it.fixed is None:
            blocks.setdefault(it_region_name(it), []).append(it.ref)
    for rnd in range(3):
        for key in floorplan.BLOCKS:
            refs = blocks.get(key, [])
            if not refs:
                continue
            mv = moves_per_item * len(refs) // (1 if rnd == 0 else 3)
            pl.anneal(refs, moves=mv)
        log('  round %d: %d blocks annealed  (%.0fs)' % (rnd, len(blocks), time.time() - t))
    ov = pl.legalize()
    log('  legalize: remaining overlaps=%d  (%.0fs)' % (len(ov), time.time() - t))
    moved = sum(pl.align(refs) for refs in blocks.values())
    log('  align: %d parts pulled onto shared rows/columns' % moved)
    for a, b, v in ov[:20]:
        log('    overlap %s %s %.2f' % (a, b, v))
    return items, pl


def apply_placement(fps, items):
    for it in items:
        if it.ref not in fps:
            continue
        fp = fps[it.ref]
        fp.SetOrientationDegrees(it.r)
        fp.SetPosition(vec(it.x, it.y))


def add_planes(board):
    inset = 0.3
    full = [(inset, inset), (W - inset, inset), (W - inset, H - inset), (inset, H - inset)]
    add_zone(board, 'GND', pcbnew.In1_Cu, full, priority=0, clearance=0.25)
    split = 22.0
    add_zone(board, '+3V3', pcbnew.In2_Cu, [(inset, inset), (split - 0.3, inset), (split - 0.3, H - inset),
                                             (inset, H - inset)], priority=1, clearance=0.25)
    add_zone(board, '+5VA', pcbnew.In2_Cu, [(split + 0.3, inset), (W - inset, inset), (W - inset, H - inset),
                                             (split + 0.3, H - inset)], priority=1, clearance=0.25)


def add_outer_pours(board):
    inset = 0.3
    full = [(inset, inset), (W - inset, inset), (W - inset, H - inset), (inset, H - inset)]
    add_zone(board, 'GND', pcbnew.F_Cu, full, priority=0, clearance=0.3, min_w=0.25)
    add_zone(board, 'GND', pcbnew.B_Cu, full, priority=0, clearance=0.3, min_w=0.25)


def add_silk(board):
    # the front is full of test-pad labels; the title lives on the back with the molecule name/version
    text(board, 'BJT + OP-AMP RADIO | STM32G0B1 USB AUDIO + DFU', W / 2, H / 2 + 7.0, size=0.9, layer=pcbnew.B_SilkS)
    text(board, 'Corner pins MP1-MP4 = GND | 5V in on contact MC1 | TP pads: see README', W / 2, H / 2 + 10.0,
         size=0.8, layer=pcbnew.B_SilkS)
    text(board, 'FM', 10.4, 81.2, size=0.8)           # LED names, in the strip floorplan.TEXT_KEEPOUTS reserves
    text(board, 'AM', 14.2, 81.2, size=0.8)
    text(board, 'STAT', 18.0, 81.2, size=0.8)
    text(board, 'MP1', MARGIN + 5.2, MARGIN, size=0.8)
    text(board, 'AM/FM Receiver Molecule\nv1.0: 26-09-28', W / 2, H / 2, size=1.2, layer=pcbnew.B_SilkS)
    text(board, 'Adom Molecule 96x96 | large pins, medium contacts | 4 layers', W / 2, H / 2 + 4.0, size=0.9,
         layer=pcbnew.B_SilkS)


def label_contacts(board):
    """Each machine contact shows its signal name on the silkscreen, pointing away from the board edge."""
    for fp in board.GetFootprints():
        ref = fp.GetReference()
        if not ref.startswith(LABELLED):
            continue
        v = fp.Value()
        v.SetText(CONTACT_LABELS.get(ref, fp.GetValue()))
        v.SetLayer(pcbnew.F_SilkS)
        v.SetVisible(True)
        tp = ref.startswith('TP')
        sz = 0.7 if tp else 0.8
        v.SetTextSize(pcbnew.VECTOR2I(mm(sz), mm(sz)))
        v.SetTextThickness(mm(0.12 if tp else 0.13))
        pos = fp.GetPosition()
        x, y = pcbnew.ToMM(pos.x), pcbnew.ToMM(pos.y)
        side = 'R' if tp else (contact_side(ref) or 'R')
        n = len(v.GetText()) * (0.55 if tp else 0.62)
        pad_r = (0.75 if fp.GetFPIDAsString().endswith('D1.0mm') else 1.0) if tp else 1.2
        off = pad_r + (0.3 if tp else 0.0) + n / 2
        dx, dy, ang = {'R': (off, 0, 0), 'L': (-off, 0, 0), 'U': (0, -off, 90), 'D': (0, off, 90)}[side]
        v.SetTextAngleDegrees(ang)
        v.SetPosition(pcbnew.VECTOR2I(mm(x + dx), mm(y + dy)))
        fp.Reference().SetLayer(pcbnew.F_Fab)
    for fp in board.GetFootprints():
        if fp.GetReference().startswith('MP'):
            fp.Reference().SetLayer(pcbnew.F_Fab)


KEEP_SILK = ('U', 'J', 'MP', 'MC', 'TP')      # parts whose own silkscreen (outline, pin-1 mark, ring) stays


def strip_part_silk(board, log=print):
    """Clean silkscreen: no reference designators, no outlines on the individual passives / discretes."""
    moved = 0
    for fp in board.GetFootprints():
        fp.Reference().SetLayer(pcbnew.F_Fab)
        if fp.GetReference().startswith(KEEP_SILK):
            continue
        for g in fp.GraphicalItems():
            if g.GetLayer() == pcbnew.F_SilkS:
                g.SetLayer(pcbnew.F_Fab)
                moved += 1
    log('  silkscreen: references moved to F.Fab, %d part outline strokes moved to F.Fab' % moved)


def silk_text(board, s, x, y, size, thick, hjust=None):
    """Board-coordinate (mm, absolute) silkscreen text; returns it."""
    t = pcbnew.PCB_TEXT(board)
    t.SetText(s)
    t.SetLayer(pcbnew.F_SilkS)
    t.SetTextSize(pcbnew.VECTOR2I(mm(size), mm(size)))
    t.SetTextThickness(mm(thick))
    if hjust == 'left':
        t.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT)
    t.SetVertJustify(pcbnew.GR_TEXT_V_ALIGN_CENTER)
    t.SetPosition(pcbnew.VECTOR2I(mm(x), mm(y)))
    board.Add(t)
    return t


def label_chips(board):
    """Each IC's part name in the strip the placer reserved beside its package."""
    for ref, name in CHIP_NAMES.items():
        fp = board.FindFootprintByReference(ref)
        cy = fp.GetCourtyard(pcbnew.F_CrtYd).BBox()
        cx = pcbnew.ToMM(cy.GetCenter().x)
        up = abs(((fp.GetOrientationDegrees() + 360) % 360) - 180) < 1       # rotated 180: strip is above
        ty = pcbnew.ToMM(cy.GetTop()) - 0.65 if up else pcbnew.ToMM(cy.GetBottom()) + 0.65
        sz = CHIP_LABEL_SIZE.get(ref, 0.8)
        silk_text(board, name, cx, ty, sz, 0.14 if sz >= 0.8 else 0.12)


def _box(bb, pad):
    return (pcbnew.ToMM(bb.GetLeft()) - pad, pcbnew.ToMM(bb.GetTop()) - pad,
            pcbnew.ToMM(bb.GetRight()) + pad, pcbnew.ToMM(bb.GetBottom()) + pad)


def draw_blocks(board, width=0.15, size=0.8, log=print):
    """Silkscreen outline around every functional block, its function written into the top edge.
    Lines break wherever they would cross a pad or other silkscreen (connectors, contact labels ...)."""
    obst = []
    for fp in board.GetFootprints():
        for p in fp.Pads():
            obst.append(_box(p.GetBoundingBox(), 0.25))
        for g in fp.GraphicalItems():
            if g.GetLayer() == pcbnew.F_SilkS and g.GetClass() != 'PCB_TEXT':
                obst.append(_box(g.GetBoundingBox(), 0.15))
        for t in (fp.Reference(), fp.Value()):
            if t.IsVisible() and t.GetLayer() == pcbnew.F_SilkS:
                obst.append(_box(t.GetBoundingBox(), 0.2))
    for d in board.GetDrawings():
        if d.GetLayer() == pcbnew.F_SilkS:
            obst.append(_box(d.GetBoundingBox(), 0.2))

    def hits(b):
        return any(not (b[2] <= o[0] or b[0] >= o[2] or b[3] <= o[1] or b[1] >= o[3]) for o in obst)
    # labels first (they are obstacles for the lines)
    for key, (label, (x0, y0, x1, y1)) in floorplan.BLOCKS.items():
        X0, Y0, X1 = OX + x0, OY + y0, OX + x1
        for dx in [0.7 + 0.5 * k for k in range(40)]:
            t = silk_text(board, label, X0 + dx, Y0, size, 0.15, hjust='left')
            b = _box(t.GetBoundingBox(), 0.15)
            if b[2] <= X1 - 0.4 and not hits(b):
                break
            board.Remove(t)
        else:
            t = silk_text(board, label, X0 + 1.0, Y0, size, 0.15, hjust='left')
            log('  WARNING: no clear spot for block label %r' % label)
        obst.append(_box(t.GetBoundingBox(), 0.3))
    n = 0

    def seg(a, b):
        nonlocal n
        s_ = pcbnew.PCB_SHAPE(board)
        s_.SetShape(pcbnew.SHAPE_T_SEGMENT)
        s_.SetStart(vec(a[0] - OX, a[1] - OY))
        s_.SetEnd(vec(b[0] - OX, b[1] - OY))
        s_.SetLayer(pcbnew.F_SilkS)
        s_.SetWidth(mm(width))
        board.Add(s_)
        n += 1

    def line(fixed, lo, hi, horiz):
        cuts = []
        for o in obst:
            if horiz and o[1] < fixed + width / 2 and o[3] > fixed - width / 2:
                cuts.append((o[0], o[2]))
            if not horiz and o[0] < fixed + width / 2 and o[2] > fixed - width / 2:
                cuts.append((o[1], o[3]))
        pos = lo
        for c0, c1 in sorted(cuts):
            if c1 <= pos or c0 >= hi:
                continue
            if c0 - pos > 0.4:
                seg((pos, fixed) if horiz else (fixed, pos), (c0, fixed) if horiz else (fixed, c0))
            pos = max(pos, c1)
        if hi - pos > 0.4:
            seg((pos, fixed) if horiz else (fixed, pos), (hi, fixed) if horiz else (fixed, hi))
    for key, (label, (x0, y0, x1, y1)) in floorplan.BLOCKS.items():
        X0, Y0, X1, Y1 = OX + x0, OY + y0, OX + x1, OY + y1
        line(Y0, X0, X1, True)
        line(Y1, X0, X1, True)
        line(X0, Y0, Y1, False)
        line(X1, Y0, Y1, False)
    log('  block outlines: %d blocks, %d silkscreen segments' % (len(floorplan.BLOCKS), n))


def save(board, path=BOARD):
    board.BuildConnectivity()
    pcbnew.SaveBoard(path, board)


def cmd_place(replace=False, add=False):
    os.makedirs(os.path.dirname(DSN), exist_ok=True)
    board, fps = build_board()
    if replace or not os.path.exists(PLACEMENT_JSON):
        seed = int(sys.argv[sys.argv.index('--seed') + 1]) if '--seed' in sys.argv else 15
        items, pl = run_placement(fps, seed=seed)
        json.dump({it.ref: [it.x, it.y, it.r] for it in items if it.ref in fps}, open(PLACEMENT_JSON, 'w'), indent=0)
    elif add:
        keep = json.load(open(PLACEMENT_JSON))
        keep = {r: v for r, v in keep.items() if r in fps}
        print('  incremental: %d parts kept, placing %d new' % (len(keep), len(fps) - len(keep)))
        items, pl = run_placement(fps, moves_per_item=4000, keep=keep)
        json.dump({it.ref: [it.x, it.y, it.r] for it in items if it.ref in fps}, open(PLACEMENT_JSON, 'w'), indent=0)
    else:
        print('  reusing', PLACEMENT_JSON)
    pos = json.load(open(PLACEMENT_JSON))
    for ref, (x, y, r) in pos.items():
        fp = fps[ref]
        fp.SetOrientationDegrees(r)
        fp.SetPosition(vec(x, y))
    add_planes(board)
    add_silk(board)
    label_contacts(board)
    board.BuildConnectivity()
    pcbgeom.ep_stubs(board)
    pcbgeom.plane_fanout(board, PLANE_NETS)
    save(board)
    save(board, PLACED)
    ok = pcbnew.ExportSpecctraDSN(board, DSN)
    print('saved', BOARD, 'dsn export', ok)


def sync_values(board):
    """Keep footprint values/fields in step with design.py (value tweaks need no re-route)."""
    parts = {p.ref: p for p in DESIGN.parts}
    for fp in board.GetFootprints():
        p = parts.get(fp.GetReference())
        if p is None:
            continue
        if fp.GetValue() != p.value:
            print('  value %s: %s -> %s' % (p.ref, fp.GetValue(), p.value))
            fp.SetValue(p.value)
        try:
            fp.GetField(pcbnew.FIELD_T_DESCRIPTION).SetText(p.description)
        except Exception:
            pass


def cmd_finish():
    board = pcbnew.LoadBoard(PLACED)     # always start from the placed, pre-routed board
    ok = pcbnew.ImportSpecctraSES(board, SES)
    print('SES import', ok)
    # reload from disk: removing freshly imported session tracks through SWIG is unreliable
    routed = os.path.join(OUT, 'build', PROJECT + '_routed.kicad_pcb')
    save(board, routed)
    board = pcbnew.LoadBoard(routed)
    minw = board.GetDesignSettings().m_TrackMinWidth
    necked = [t for t in board.GetTracks() if t.GetClass() != 'PCB_VIA' and t.GetWidth() < minw]
    import route_fix
    for t in necked:
        board.Remove(t)                     # the router necks down between pads; re-route those gaps below
        route_fix.GRAVEYARD.append(t)
    print('  removed %d necked-down (< %.2f mm) track segments for re-routing' % (len(necked), pcbnew.ToMM(minw)))
    assign_netclasses(board.GetDesignSettings().m_NetSettings)
    board.SynchronizeNetsAndNetClasses(True)
    sync_values(board)
    import route_fix
    route_fix.remove_dangling(board)
    # fill the planes so plane-connected pads count as connected, then close what the router left open
    filler = pcbnew.ZONE_FILLER(board)
    filler.Fill(board.Zones())
    import route_fix
    rf = set(kicad_net_name(n) for n in RF_CLASS)
    pw = set(kicad_net_name(n) for n in POWER_CLASS)
    route_fix.fix_with_ripup(board, OX, OY, W, H,
                  width_of=lambda n: 0.36 if n in rf else (0.3 if n in pw else 0.2),
                  clearance_of=lambda n: 0.26 if n in rf else 0.2)
    route_fix.remove_dangling(board)
    add_outer_pours(board)
    board.BuildConnectivity()
    pcbgeom.stitch_gnd(board, W, H, OX, OY)
    pcbgeom.trim_edge_silk(board, OX, OY, W, H)
    pcbgeom.place_board_texts(board, W, H, OX, OY)
    strip_part_silk(board)
    label_chips(board)
    draw_blocks(board)
    # drill/place origin at the board's lower-left corner (Gerbers, drill and CPL share it)
    board.GetDesignSettings().SetAuxOrigin(vec(MARGIN, MARGIN))     # MP1 = molecule origin
    board.GetDesignSettings().SetGridOrigin(vec(MARGIN, MARGIN))
    filler = pcbnew.ZONE_FILLER(board)
    filler.Fill(board.Zones())
    save(board)
    print('saved', BOARD)



def cmd_repair(max_iter=4):
    """DRC-driven clean-up: rip up router copper involved in clearance errors and re-route it."""
    import subprocess, route_fix
    for it in range(max_iter):
        out = os.path.join(OUT, 'build', 'drc_repair.json')
        subprocess.run(['kicad-cli', 'pcb', 'drc', '--format', 'json', '-o', out, BOARD], capture_output=True)
        d = json.load(open(out))
        bad = [v for v in d.get('violations', []) if v['severity'] == 'error' and
               v['type'] in ('clearance', 'shorting_items', 'tracks_crossing', 'hole_clearance', 'via_dangling')]
        bad += [v for v in d.get('violations', []) if v['type'] == 'via_dangling']
        if not bad and not d.get('unconnected_items'):
            print('  repair: DRC clean after %d pass(es)' % it)
            return
        board = pcbnew.LoadBoard(BOARD)
        uu = set()
        for v in bad:
            for i in v['items']:
                if i['description'].startswith(('Track', 'Via')):
                    uu.add(i['uuid'])
        removed = 0
        for t in list(board.GetTracks()):
            if t.m_Uuid.AsString() in uu and not t.IsLocked():
                board.Remove(t)
                route_fix.GRAVEYARD.append(t)
                removed += 1
        print('  repair pass %d: %d DRC errors, ripped %d track/via items' % (it + 1, len(bad), removed))
        route_fix.remove_dangling(board)
        filler = pcbnew.ZONE_FILLER(board)
        filler.Fill(board.Zones())
        rf = set(kicad_net_name(n) for n in RF_CLASS)
        pw = set(kicad_net_name(n) for n in POWER_CLASS)
        route_fix.fix_with_ripup(board, OX, OY, W, H,
                                 width_of=lambda n: 0.36 if n in rf else (0.3 if n in pw else 0.2),
                                 clearance_of=lambda n: 0.26 if n in rf else 0.2)
        route_fix.remove_dangling(board)
        filler.Fill(board.Zones())
        still_open = len(route_fix.net_islands(board))
        before = d.get('unconnected_items', [])
        if still_open > len(before):
            print('  repair pass %d made things worse (%d open nets vs %d before): not saved' % (
                it + 1, still_open, len(before)))
            return
        save(board)


if __name__ == '__main__':
    cmd = sys.argv[1] if len(sys.argv) > 1 else 'place'
    if cmd == 'place':
        cmd_place(replace='--replace' in sys.argv, add='--add' in sys.argv)
    elif cmd == 'finish':
        cmd_finish()
    elif cmd == 'repair':
        cmd_repair()