- 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>
358 lines
17 KiB
Python
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)
|