molecule
AM/FM Receiver Molecule
Public Made by Adomby adom
USB-powered AM/FM receiver on an Adom Molecule: discrete BJT and op-amp superhet (10.7 MHz and 455 kHz IFs), STM32G0B1 tuning, self-calibration and USB audio over one USB-C cable. 96 x 96 mm pin grid, 4 layers, KiCad 10.
main
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()