Files
sandTable/pcb/tools/board.py
T
ckochandClaude Opus 5.5 1400cc8afd Controller board: ESP32 + 2x StepStick carrier (v0.4), FluidNC config
- KiCad project generated by pcb/tools (design, route, schematic, fab outputs).
- v0.4 fixes the v0.3 review findings: low-dropout 3.3 V regulator, OR'd 5 V
  supply, polyfuse, USB ESD protection, input clamps, ceramic decoupling at the
  drivers, A4988/DRV8825 jumper, ground stitching. DRC/ERC clean.
- v0.3 (as ordered) kept in pcb/archive/v0.3.
- FluidNC config.yaml for the board; rho hard limit on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:40:23 -05:00

358 lines
17 KiB
Python

"""Board model: placed footprints, absolute pad geometry, KiCad file writers."""
import copy
import json
import math
import os
import uuid as uuidlib
import design as D
from kilib import load_footprint, fp_pads, fp_courtyard
from sexpr import QStr, dumps, find, findall
OX, OY = D.ORIGIN
# Driver sockets: only the two female header strips sit on the board. The plugged-in module is
# about 8.5 mm up, so low parts (the VMOT ceramics) may go between the strips.
POLOLU = 'Module:Pololu_Breakout-16_15.2x20.3mm'
POLOLU_COURT = [(-1.53, -1.52, 1.53, 19.3), (11.17, -1.52, 14.21, 19.3)]
def uid(*parts):
"""Deterministic UUID so regenerated files diff cleanly."""
return str(uuidlib.uuid5(uuidlib.NAMESPACE_URL, 'sandtable-pcb/' + '/'.join(map(str, parts))))
def rot_pt(x, y, deg):
"""KiCad footprint transform (y down, positive = CCW on screen)."""
t = math.radians(deg)
c, s = round(math.cos(t), 12), round(math.sin(t), 12)
return (x * c + y * s, -x * s + y * c)
class Pad:
__slots__ = ('ref', 'num', 'net', 'x', 'y', 'hx', 'hy', 'shape', 'layers', 'drill', 'kind')
def dist(self, px, py):
"""Distance from point to pad copper (0 inside)."""
dx = abs(px - self.x)
dy = abs(py - self.y)
if self.shape == 'circle':
return max(0.0, math.hypot(dx, dy) - self.hx)
if self.shape == 'oval':
if self.hx >= self.hy:
r = self.hy
ex = max(0.0, dx - (self.hx - r))
return max(0.0, math.hypot(ex, dy) - r)
r = self.hx
ey = max(0.0, dy - (self.hy - r))
return max(0.0, math.hypot(dx, ey) - r)
ex = max(0.0, dx - self.hx)
ey = max(0.0, dy - self.hy)
return math.hypot(ex, ey)
class Part:
def __init__(self, ref, spec):
self.ref = ref
(self.sym, self.fp_id, self.value, self.x, self.y, self.rot,
self.lcsc, self.descr) = spec
self.fp = load_footprint(self.fp_id, min_drill=D.MIN_DRILL, min_ring=D.MIN_RING, solid_thermal_pads=True)
self.raw_pads = fp_pads(self.fp)
self.pin_net = {}
def court_rects(self):
"""Courtyard as axis-aligned rects in board coords."""
if self.fp_id == 'RF_Module:ESP32-WROOM-32E':
local = [(-9.75, -6.31, 9.75, 13.54), (-24.25, -28.0, 24.25, -6.31)]
elif self.fp_id == POLOLU:
local = POLOLU_COURT
else:
c = fp_courtyard(self.fp)
local = [c] if c else []
out = []
for (x0, y0, x1, y1) in local:
pts = [rot_pt(x, y, self.rot) for x, y in ((x0, y0), (x1, y0), (x0, y1), (x1, y1))]
xs = [p[0] + self.x for p in pts]
ys = [p[1] + self.y for p in pts]
out.append((min(xs), min(ys), max(xs), max(ys)))
return out
def pads(self):
out = []
for p in self.raw_pads:
if p['kind'] == 'connect':
continue
px, py = rot_pt(p['x'], p['y'], self.rot)
ang = (p['rot'] + self.rot) % 180
w, h = p['w'], p['h']
if abs(ang - 90) < 1:
w, h = h, w
pad = Pad()
pad.ref = self.ref
pad.num = p['num']
pad.net = self.pin_net.get(p['num'])
pad.x = self.x + px
pad.y = self.y + py
pad.hx, pad.hy = w / 2, h / 2
shape = p['shape']
pad.shape = 'circle' if shape == 'circle' else ('oval' if shape == 'oval' else 'rect')
if pad.shape == 'circle':
pad.hx = pad.hy = w / 2
pad.kind = p['kind']
if p['kind'] == 'np_thru_hole':
pad.layers = ()
elif any(l in ('*.Cu', 'F&B.Cu') for l in p['layers']):
pad.layers = ('F.Cu', 'B.Cu')
else:
pad.layers = tuple(l for l in p['layers'] if l in ('F.Cu', 'B.Cu'))
pad.drill = p['drill']
out.append(pad)
return out
def build():
parts = {ref: Part(ref, spec) for ref, spec in D.PARTS.items()}
for net, pins in D.NETS.items():
for pin in pins:
ref, num = pin.split('.', 1)
if ref not in parts:
raise KeyError(f'net {net}: unknown part {ref}')
nums = {p['num'] for p in parts[ref].raw_pads}
if num not in nums:
raise KeyError(f'net {net}: {ref} has no pad {num}')
if num in parts[ref].pin_net:
raise ValueError(f'{pin} in two nets')
parts[ref].pin_net[num] = net
return parts
def check_placement(parts):
problems = []
rects = []
for p in parts.values():
for r in p.court_rects():
rects.append((p.ref, r))
for i in range(len(rects)):
for j in range(i + 1, len(rects)):
a, ra = rects[i]
b, rb = rects[j]
if a == b:
continue
ox = min(ra[2], rb[2]) - max(ra[0], rb[0])
oy = min(ra[3], rb[3]) - max(ra[1], rb[1])
if ox > 0.001 and oy > 0.001:
problems.append(f'courtyard overlap {a} / {b}: {ox:.2f} x {oy:.2f}')
for p in parts.values():
for pad in p.pads():
if pad.layers and (pad.x - pad.hx < 0.3 or pad.y - pad.hy < 0.3 or
pad.x + pad.hx > D.BOARD_W - 0.3 or pad.y + pad.hy > D.BOARD_H - 0.3):
problems.append(f'pad near/outside edge {p.ref}.{pad.num} at ({pad.x:.2f},{pad.y:.2f})')
return problems
# ---------------------------------------------------------------- writers
def pcb_name(net):
"""KiCad names nets from local labels with the sheet path prefix."""
return net if net.startswith('unconnected-') else '/' + net
def nc_nets():
"""Single-pad nets KiCad creates for unconnected symbol pins: (ref, pad) -> name."""
import sch
from kilib import sym_pins
connected = {p for pins in D.NETS.values() for p in pins}
out = {}
for ref, spec in D.PARTS.items():
full, sym, _ = sch.symbol_def(spec[0])
for pin in sym_pins(sym):
key = f'{ref}.{pin["num"]}'
if key not in connected:
out[(ref, pin['num'])] = f'unconnected-({ref}-{pin["name"]}-Pad{pin["num"]})'
return out
def net_list():
names = sorted(D.NETS.keys(), key=lambda n: (n != 'GND', n))
ids = {n: i + 1 for i, n in enumerate(names)}
for name in sorted(set(nc_nets().values())):
ids[name] = len(ids) + 1
return ids
NC_NETS = {}
def footprint_node(part, net_ids, sheet_file):
fp = copy.deepcopy(part.fp)
keep = [x for x in fp[2:] if not (isinstance(x, list) and x and x[0] in
('version', 'generator', 'generator_version', 'layer', 'zone'))]
if part.fp_id == POLOLU:
def on_crtyd(x):
lay = find(x, 'layer') if isinstance(x, list) else None
return lay is not None and str(lay[1]) == 'F.CrtYd'
keep = [x for x in keep if not (isinstance(x, list) and x and x[0] in ('fp_line', 'fp_rect') and on_crtyd(x))]
for i, (x0, y0, x1, y1) in enumerate(POLOLU_COURT):
keep.append(['fp_rect', ['start', fmt(x0), fmt(y0)], ['end', fmt(x1), fmt(y1)],
['stroke', ['width', '0.05'], ['type', 'solid']], ['fill', 'no'],
['layer', QStr('F.CrtYd')], ['uuid', QStr(uid('crtyd', part.ref, i))]])
node = ['footprint', QStr(part.fp_id), ['layer', QStr('F.Cu')], ['uuid', QStr(uid('fp', part.ref))],
['at', fmt(part.x + OX), fmt(part.y + OY)] + ([fmt(part.rot)] if part.rot else [])]
for x in keep:
if isinstance(x, list) and x and x[0] == 'property':
name = str(x[1])
if name == 'Reference':
x[2] = QStr(part.ref)
elif name == 'Value':
x[2] = QStr(part.value)
small = part.fp_id.split(':')[0] in ('Resistor_SMD', 'LED_SMD', 'Button_Switch_SMD') or \
(part.fp_id.startswith('Capacitor_SMD:C_')) or part.fp_id.endswith(('SOT-23', 'SOT-23-5', 'D_SOD-123'))
if name == 'Reference' and small:
eff = find(x, 'effects')
fnt = find(eff, 'font') if eff else None
if fnt is not None:
sz, th = find(fnt, 'size'), find(fnt, 'thickness')
if sz is not None:
sz[1:] = ['0.7', '0.7']
if th is not None:
th[1] = '0.1'
at = find(x, 'at')
if at is not None and name == 'Reference' and part.ref in getattr(D, 'REF_POS', {}) and not part.rot:
at[1], at[2] = fmt(D.REF_POS[part.ref][0]), fmt(D.REF_POS[part.ref][1])
if at is not None:
# absolute text angle: keep text readable (0 or 90)
while len(at) < 4:
at.append('0')
at[3] = '0' if name in ('Reference', 'Value') else fmt(part.rot)
if find(x, 'uuid') is None:
x.insert(3, ['uuid', QStr(uid('prop', part.ref, name))])
if isinstance(x, list) and x and x[0] == 'pad':
at = find(x, 'at')
local = float(at[3]) if len(at) > 3 else 0.0
ang = (local + part.rot) % 360
del at[3:]
if ang:
at.append(fmt(ang))
num = str(x[1])
net = part.pin_net.get(num) or NC_NETS.get((part.ref, num))
if net and x[2] != 'np_thru_hole':
x.append(['net', str(net_ids[net]), QStr(pcb_name(net))])
x.append(['uuid', QStr(uid('pad', part.ref, num, len(x)))])
node.append(x)
# extra properties
node.insert(5, ['property', QStr('LCSC'), QStr(part.lcsc), ['at', '0', '0', '0'], ['layer', QStr('F.Fab')],
['hide', 'yes'], ['uuid', QStr(uid('prop', part.ref, 'LCSC'))],
['effects', ['font', ['size', '1', '1'], ['thickness', '0.15']]]])
node.append(['path', QStr('/' + uid('sym', part.ref))])
node.append(['sheetname', QStr('/')])
node.append(['sheetfile', QStr(sheet_file)])
return node
def fmt(v):
s = f'{float(v):.4f}'.rstrip('0').rstrip('.')
return '0' if s in ('-0', '') else s
HEADER_LAYERS = ['layers',
['0', QStr('F.Cu'), 'signal'], ['2', QStr('B.Cu'), 'signal'],
['9', QStr('F.Adhes'), 'user', QStr('F.Adhesive')], ['11', QStr('B.Adhes'), 'user', QStr('B.Adhesive')],
['13', QStr('F.Paste'), 'user'], ['15', QStr('B.Paste'), 'user'],
['5', QStr('F.SilkS'), 'user', QStr('F.Silkscreen')], ['7', QStr('B.SilkS'), 'user', QStr('B.Silkscreen')],
['1', QStr('F.Mask'), 'user'], ['3', QStr('B.Mask'), 'user'],
['17', QStr('Dwgs.User'), 'user', QStr('User.Drawings')], ['19', QStr('Cmts.User'), 'user', QStr('User.Comments')],
['21', QStr('Eco1.User'), 'user', QStr('User.Eco1')], ['23', QStr('Eco2.User'), 'user', QStr('User.Eco2')],
['25', QStr('Edge.Cuts'), 'user'], ['27', QStr('Margin'), 'user'],
['31', QStr('F.CrtYd'), 'user', QStr('F.Courtyard')], ['29', QStr('B.CrtYd'), 'user', QStr('B.Courtyard')],
['35', QStr('F.Fab'), 'user'], ['33', QStr('B.Fab'), 'user']]
def text_node(txt, x, y, layer, size=1.2, angle=0, bold=False, justify=None):
eff = ['effects', ['font', ['size', fmt(size), fmt(size)], ['thickness', fmt(size * 0.15)]] + ([['bold', 'yes']] if bold else [])]
if justify:
eff.append(['justify'] + justify)
if layer.startswith('B.'):
eff.append(['justify', 'mirror'])
return ['gr_text', QStr(txt), ['at', fmt(x + OX), fmt(y + OY), fmt(angle)], ['layer', QStr(layer)],
['uuid', QStr(uid('txt', txt, x, y, layer))], eff]
def write_pcb(path, parts, tracks=(), vias=(), zones=(), texts=(), sheet_file='sandtable-controller.kicad_sch'):
NC_NETS.clear()
NC_NETS.update(nc_nets())
net_ids = net_list()
root = ['kicad_pcb', ['version', '20241229'], ['generator', QStr('pcbnew')], ['generator_version', QStr('9.0')],
['general', ['thickness', '1.6'], ['legacy_teardrops', 'no']], ['paper', QStr('A4')],
['title_block', ['title', QStr('SandTable Controller')], ['date', QStr('2026-10-22')], ['rev', QStr(D.VERSION)],
['company', QStr('lazypugs')], ['comment', '1', QStr('ESP32 + 2x DRV8825 StepStick, drop-in for the Uno bay')]],
HEADER_LAYERS,
['setup', ['pad_to_mask_clearance', '0'], ['allow_soldermask_bridges_in_footprints', 'no'],
['tenting', 'front', 'back'], ['aux_axis_origin', fmt(OX), fmt(OY + D.BOARD_H)],
['pcbplotparams', ['layerselection', '0x00000000_00000000_55555555_5755f5ff'],
['plot_on_all_layers_selection', '0x00000000_00000000_00000000_00000000'],
['disableapertmacros', 'no'], ['usegerberextensions', 'no'], ['usegerberattributes', 'yes'],
['usegerberadvancedattributes', 'yes'], ['creategerberjobfile', 'yes'], ['svgprecision', '4'],
['plotframeref', 'no'], ['mode', '1'], ['useauxorigin', 'no'], ['outputformat', '1'],
['mirror', 'no'], ['drillshape', '1'], ['scaleselection', '1'], ['outputdirectory', QStr('')]]],
['net', '0', QStr('')]]
for name, i in sorted(net_ids.items(), key=lambda kv: kv[1]):
root.append(['net', str(i), QStr(pcb_name(name))])
for ref in sorted(parts):
root.append(footprint_node(parts[ref], net_ids, sheet_file))
# board outline
root.append(['gr_rect', ['start', fmt(OX), fmt(OY)], ['end', fmt(OX + D.BOARD_W), fmt(OY + D.BOARD_H)],
['stroke', ['width', '0.05'], ['type', 'default']], ['fill', 'no'], ['layer', QStr('Edge.Cuts')],
['uuid', QStr(uid('edge'))]])
root.extend(texts)
for (x1, y1, x2, y2, w, layer, net) in tracks:
root.append(['segment', ['start', fmt(x1 + OX), fmt(y1 + OY)], ['end', fmt(x2 + OX), fmt(y2 + OY)],
['width', fmt(w)], ['layer', QStr(layer)], ['net', str(net_ids[net])],
['uuid', QStr(uid('seg', x1, y1, x2, y2, layer, net))]])
for (x, y, net) in vias:
root.append(['via', ['at', fmt(x + OX), fmt(y + OY)], ['size', fmt(D.VIA[0])], ['drill', fmt(D.VIA[1])],
['layers', QStr('F.Cu'), QStr('B.Cu')], ['net', str(net_ids[net])],
['uuid', QStr(uid('via', x, y, net))]])
root.extend(zones)
with open(path, 'w') as f:
f.write(dumps(root) + '\n')
return net_ids
def keepout_zone(name, x0, y0, x1, y1):
return ['zone', ['net', '0'], ['net_name', QStr('')], ['layers', QStr('F.Cu'), QStr('B.Cu')],
['uuid', QStr(uid('zone', name))], ['name', QStr(name)], ['hatch', 'edge', '0.5'],
['connect_pads', ['clearance', '0']], ['min_thickness', '0.25'], ['filled_areas_thickness', 'no'],
['keepout', ['tracks', 'not_allowed'], ['vias', 'not_allowed'], ['pads', 'not_allowed'],
['copperpour', 'not_allowed'], ['footprints', 'allowed']],
['placement', ['enabled', 'no'], ['sheetname', QStr('')]],
['fill', ['thermal_gap', '0.5'], ['thermal_bridge_width', '0.5']],
['polygon', ['pts'] + [['xy', fmt(x + OX), fmt(y + OY)] for x, y in
((x0, y0), (x1, y0), (x1, y1), (x0, y1))]]]
def write_project(path, name):
tmpl = '/snap/kicad/current/usr/share/kicad/template/API_Series-500/API_Series-500.kicad_pro'
with open(tmpl) as f:
pro = json.load(f)
pro['meta']['filename'] = name + '.kicad_pro'
base = pro['net_settings']['classes'][0]
classes = []
for cname, (w, clr) in D.NETCLASS.items():
c = dict(base)
c.update(name=cname, track_width=w, clearance=clr, via_diameter=D.VIA[0], via_drill=D.VIA[1],
priority=2147483647 if cname == 'Default' else 0)
classes.append(c)
pro['net_settings']['classes'] = classes
pro['net_settings']['netclass_patterns'] = [{'netclass': c, 'pattern': pcb_name(n)} for n, c in D.NET_TO_CLASS.items()]
rules = pro['board']['design_settings']['rules']
# PCBWay standard process limits
rules.update(min_clearance=0.16, min_track_width=0.16, min_via_diameter=0.6, min_through_hole_diameter=D.MIN_DRILL,
min_copper_edge_clearance=0.3, min_hole_clearance=0.2, min_hole_to_hole=0.25,
min_via_annular_width=0.15, min_silk_clearance=0.0)
sev = pro['board']['design_settings']['rule_severities']
sev.update({'silk_over_copper': 'ignore', 'silk_overlap': 'ignore', 'silk_edge_clearance': 'ignore',
'lib_footprint_issues': 'ignore', 'lib_footprint_mismatch': 'ignore', 'text_height': 'ignore'})
pro.pop('boards', None)
pro['sheets'] = [[uid('rootsheet'), 'Root']]
with open(path, 'w') as f:
json.dump(pro, f, indent=2)