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>
This commit is contained in:
1 parent
c498882bbb
commit
1400cc8afd
46 files changed
+226470
No files matched your search
@@ -0,0 +1,357 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user