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:
ckochandClaude Opus 5.5 committed 2026-11-01 13:40:23 -05:00
1 parent c498882bbb
commit 1400cc8afd
46 files changed
+226470

No files matched your search

+357
View File
@@ -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)
+203
View File
@@ -0,0 +1,203 @@
"""SandTable Controller (version in VERSION): single source of truth for parts, placement and nets.
Board coordinates are mm, y down, origin at the board's top-left corner.
x = 0 is the edge that faces the table wall (power + USB); x = 80 faces the
table centre (motor/sensor connectors). The 4 mounting holes reuse the
Arduino Uno hole pattern so the board drops onto the existing deck standoffs.
"""
VERSION = '0.4'
BOARD_W = 80.0
BOARD_H = 63.34
ORIGIN = (100.0, 100.0) # where board (0,0) lands in KiCad coordinates
# ---------------------------------------------------------------- parts
# ref: (symbol lib_id, footprint lib_id, value, x, y, rot, lcsc, description)
PARTS = {
'U1': ('RF_Module:ESP32-WROOM-32E', 'RF_Module:ESP32-WROOM-32E', 'ESP32-WROOM-32E-N8', 43.0, 50.59, 180, '', 'Wi-Fi MCU module (FluidNC)'),
'U2': ('Interface_USB:CH340C', 'Package_SO:SOIC-16_3.9x9.9mm_P1.27mm', 'CH340C', 22.0, 47.0, 0, 'C84681', 'USB-UART bridge'),
'U3': ('Regulator_Linear:AP7361C-33E', 'Package_TO_SOT_SMD:SOT-223-3_TabPin2', 'AP7361C-33E', 66.0, 41.5, 0, '', '5V -> 3.3V LDO, 0.36 V dropout at 1 A (pin 1 IN, 2 GND/tab, 3 OUT)'),
'U4': ('74xGxx:74AHCT1G125', 'Package_TO_SOT_SMD:SOT-23-5', '74AHCT1G125', 65.5, 30.0, 0, '', '3.3V -> 5V LED data buffer'),
'PS1': ('Converter_DCDC:R-78E5.0-1.0', 'Converter_DCDC:Converter_DCDC_RECOM_R-78E-0.5_THT', 'R-78E5.0-1.0', 18.0, 35.0, 0, '', '12V -> 5V 1A switching regulator'),
'A1': ('Driver_Motor:Pololu_Breakout_A4988', 'Module:Pololu_Breakout-16_15.2x20.3mm', 'DRV8825 StepStick (theta/spin)', 38.1, 1.9, 270, '', 'Stepper driver socket (DRV8825, 1/32 step)'),
'A2': ('Driver_Motor:Pololu_Breakout_A4988', 'Module:Pololu_Breakout-16_15.2x20.3mm', 'DRV8825 StepStick (rho/arm)', 59.1, 1.9, 270, '', 'Stepper driver socket (DRV8825, 1/32 step)'),
'J1': ('Connector:Barrel_Jack', 'Connector_BarrelJack:BarrelJack_CUI_PJ-063AH_Horizontal', '12V DC 5.5x2.1', 12.3, 25.0, 270, '', 'Power input'),
'J2': ('Connector:USB_C_Receptacle_USB2.0_16P', 'Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12', 'USB-C', 3.4, 44.0, 270, 'C165948', 'Programming / serial'),
'J3': ('Connector_Generic:Conn_01x04', 'Connector_JST:JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical', 'SPIN MOTOR', 4.5, 3.4, 270, 'C158012', 'Theta motor (XH-4, matches motor cable)'),
'J4': ('Connector_Generic:Conn_01x04', 'Connector_JST:JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical', 'ARM MOTOR', 76.5, 3.4, 270, 'C158012', 'Rho motor via slip ring'),
'J5': ('Connector_Generic:Conn_01x03', 'Connector_JST:JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical', 'HOME SW', 76.5, 18.5, 270, 'C144394', 'Rho home switch (via slip ring)'),
'J6': ('Connector_Generic:Conn_01x03', 'Connector_JST:JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical', 'HALL', 76.5, 29.6, 270, 'C144394', 'Optional theta hall sensor (5V)'),
'J7': ('Connector_Generic:Conn_01x03', 'Connector_JST:JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical', 'LED STRIP', 76.5, 40.7, 270, 'C144394', 'WS2812B strip (5V, small strips only)'),
'J8': ('Connector_Generic:Conn_01x04', 'Connector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_Vertical', 'EXPANSION', 77.5, 52.0, 0, '', '3V3 / IO32 / IO25 / GND'),
'D1': ('Device:D_Schottky', 'Diode_SMD:D_SMA', 'SS34', 16.0, 22.0, 90, 'C8678', 'Reverse-polarity protection'),
'D2': ('Device:D_Schottky', 'Diode_SMD:D_SMA', 'SS34', 28.87, 33.0, 90, 'C8678', '5V OR-ing (regulator, 1 A)'),
'D3': ('Device:D_Schottky', 'Diode_SMD:D_SOD-123', 'B5819W', 13.0, 39.8, 0, 'C8598', '5V OR-ing (USB)'),
'D4': ('Device:LED', 'LED_SMD:LED_0603_1608Metric', 'GREEN', 34.0, 30.0, 0, 'C72043', 'Power LED'),
'D5': ('Device:LED', 'LED_SMD:LED_0603_1608Metric', 'BLUE', 38.0, 30.0, 0, 'C72041', 'Status LED (IO2)'),
'Q1': ('Transistor_BJT:MMBT3904', 'Package_TO_SOT_SMD:SOT-23', 'MMBT3904', 28.0, 47.2, 0, 'C20526', 'Auto-reset (EN)'),
'Q2': ('Transistor_BJT:MMBT3904', 'Package_TO_SOT_SMD:SOT-23', 'MMBT3904', 28.0, 51.2, 0, 'C20526', 'Auto-reset (IO0)'),
'SW1': ('Switch:SW_Push', 'Button_Switch_SMD:SW_SPST_B3U-1000P', 'RESET', 27.5, 55.0, 0, 'C231329', 'EN / reset button'),
'SW2': ('Switch:SW_Push', 'Button_Switch_SMD:SW_SPST_B3U-1000P', 'BOOT', 22.0, 55.0, 0, 'C231329', 'IO0 / boot button'),
'C1': ('Device:C_Polarized', 'Capacitor_SMD:CP_Elec_8x10', '100uF 25V', 24.0, 21.0, 0, '', 'Theta driver VMOT bulk'),
'C2': ('Device:C_Polarized', 'Capacitor_SMD:CP_Elec_8x10', '100uF 25V', 45.5, 21.0, 0, '', 'Rho driver VMOT bulk'),
'C3': ('Device:C', 'Capacitor_SMD:C_1206_3216Metric', '10uF 25V', 20.5, 39.6, 0, 'C13585', '12V input decoupling'),
'C4': ('Device:C', 'Capacitor_SMD:C_1206_3216Metric', '10uF 16V', 26.0, 39.6, 0, 'C13585', 'Regulator output'),
'C5': ('Device:C', 'Capacitor_SMD:C_0805_2012Metric', '10uF', 59.6, 44.0, 90, 'C15850', 'LDO input'),
'C6': ('Device:C', 'Capacitor_SMD:C_0805_2012Metric', '22uF', 59.6, 39.5, 90, 'C45783', 'LDO output'),
'C7': ('Device:C', 'Capacitor_SMD:C_0603_1608Metric', '100nF', 27.0, 43.0, 90, 'C14663', 'CH340C decoupling'),
'C8': ('Device:C', 'Capacitor_SMD:C_0603_1608Metric', '1uF', 54.6, 52.7, 0, 'C15849', 'EN reset delay'),
'C9': ('Device:C', 'Capacitor_SMD:C_0805_2012Metric', '10uF', 57.5, 54.6, 90, 'C15850', 'ESP32 bulk decoupling'),
'C10': ('Device:C', 'Capacitor_SMD:C_0603_1608Metric', '100nF', 55.0, 55.0, 90, 'C14663', 'ESP32 decoupling'),
'C13': ('Device:C', 'Capacitor_SMD:C_0603_1608Metric', '100nF', 61.0, 18.5, 0, 'C14663', 'Home switch filter'),
'C14': ('Device:C', 'Capacitor_SMD:C_0603_1608Metric', '100nF', 61.0, 20.7, 0, 'C14663', 'Hall filter'),
'C15': ('Device:C', 'Capacitor_SMD:C_0603_1608Metric', '100nF', 61.0, 30.0, 90, 'C14663', 'Buffer decoupling'),
'R1': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '5.1k', 11.5, 42.75, 0, 'C23186', 'USB-C CC1 pull-down'),
'R2': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '5.1k', 11.5, 45.75, 0, 'C23186', 'USB-C CC2 pull-down'),
'R3': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '10k', 31.0, 45.0, 90, 'C25804', 'Q1 base'),
'R4': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '10k', 31.0, 49.5, 90, 'C25804', 'Q2 base'),
'R5': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '10k', 54.6, 51.0, 0, 'C25804', 'EN pull-up'),
'R6': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '10k', 31.0, 37.5, 0, 'C25804', 'IO0 pull-up'),
'R8': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '10k', 47.0, 31.0, 0, 'C25804', 'Driver enable pull-up (off at boot)'),
'R9': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '10k', 54.0, 18.5, 0, 'C25804', 'Home switch pull-up'),
'R10': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '1k', 57.5, 18.5, 0, 'C21190', 'Home switch series'),
'R11': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '10k', 54.0, 20.7, 0, 'C25804', 'Hall pull-up'),
'R12': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '1k', 57.5, 20.7, 0, 'C21190', 'Hall series'),
'R13': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '330', 70.0, 30.0, 90, 'C23138', 'LED data series'),
'R14': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '1k', 34.0, 32.5, 0, 'C21190', 'Power LED'),
'R15': ('Device:R', 'Resistor_SMD:R_0603_1608Metric', '1k', 38.0, 32.5, 0, 'C21190', 'Status LED'),
# v0.4 additions (positions set by tools/place_new.py)
'C16': ('Device:C', 'Capacitor_SMD:C_1206_3216Metric', '10uF 50V', 21.8, 5.4, 0, '', 'Theta driver VMOT ceramic, at the socket pins'),
'C17': ('Device:C', 'Capacitor_SMD:C_1206_3216Metric', '10uF 50V', 42.8, 5.4, 0, '', 'Rho driver VMOT ceramic, at the socket pins'),
'F1': ('Device:Polyfuse', 'Fuse:Fuse_1812_4532Metric', '2.5A PTC', 12.6, 16.2, 0, '', '12V input resettable fuse'),
'U5': ('Power_Protection:USBLC6-2SC6', 'Package_TO_SOT_SMD:SOT-23-6', 'USBLC6-2SC6', 13.0, 49.5, 0, '', 'USB data ESD protection'),
'D6': ('Device:D_Schottky', 'Diode_SMD:D_SOD-323', 'BAT54WS', 64.5, 18.0, 0, '', 'Home input clamp to 3.3V (5V-safe)'),
'D7': ('Device:D_Schottky', 'Diode_SMD:D_SOD-323', 'BAT54WS', 64.5, 15.85, 0, '', 'Hall input clamp to 3.3V (5V-safe)'),
'JP1': ('Jumper:SolderJumper_2_Open', 'Jumper:SolderJumper-2_P1.3mm_Open_Pad1.0x1.5mm', 'A4988', 36.0, 8.0, 0, '', 'Driver pin 2 to 3.3V: bridge only for A4988 (VDD); leave open for DRV8825 (FAULT)'),
'H1': ('Mechanical:MountingHole', 'MountingHole:MountingHole_3.2mm_M3', 'M3', 15.24, 7.54, 0, '', 'Uno hole'),
'H2': ('Mechanical:MountingHole', 'MountingHole:MountingHole_3.2mm_M3', 'M3', 13.97, 55.8, 0, '', 'Uno hole'),
'H3': ('Mechanical:MountingHole', 'MountingHole:MountingHole_3.2mm_M3', 'M3', 66.04, 22.78, 0, '', 'Uno hole'),
'H4': ('Mechanical:MountingHole', 'MountingHole:MountingHole_3.2mm_M3', 'M3', 66.04, 50.72, 0, '', 'Uno hole'),
}
# Silkscreen reference positions that would otherwise collide (offset from the part origin, mm)
REF_POS = {'C16': (3.6, 0.0), 'C17': (3.6, 0.0), 'D6': (2.9, 0.0), 'D7': (2.9, 0.0), 'SW1': (0.0, 2.7), 'H3': (0.0, 4.4)}
# ---------------------------------------------------------------- nets
NETS = {
'GND': ['J1.2', 'J2.A1', 'J2.A12', 'J2.B1', 'J2.B12', 'J2.S1', 'C1.2', 'C2.2', 'C3.2', 'C4.2', 'C5.2',
'C6.2', 'C7.2', 'C8.2', 'C9.2', 'C10.2', 'C13.2', 'C14.2', 'C15.2', 'PS1.2', 'U3.2', 'U2.1',
'C16.2', 'C17.2', 'U5.2',
'U1.1', 'U1.15', 'U1.38', 'U1.39', 'A1.1', 'A1.7', 'A2.1', 'A2.7',
'J5.3', 'J6.3', 'J7.3', 'J8.4', 'SW1.2', 'SW2.2', 'R1.2', 'R2.2', 'D4.1', 'D5.1', 'U4.1', 'U4.3'],
'VIN_RAW': ['J1.1', 'F1.1'],
'VIN_FUSED': ['F1.2', 'D1.2'],
'+12V': ['D1.1', 'C1.1', 'C2.1', 'C3.1', 'PS1.1', 'A1.8', 'A2.8', 'C16.1', 'C17.1'],
'+5V_REG': ['PS1.3', 'C4.1', 'D2.2'],
'VBUS': ['J2.A4', 'J2.A9', 'J2.B4', 'J2.B9', 'D3.2', 'U5.5'],
'+5V': ['D2.1', 'D3.1', 'U3.1', 'C5.1', 'U4.5', 'C15.1', 'J6.1', 'J7.1'],
'+3V3': ['U3.3', 'C6.1', 'U1.2', 'C9.1', 'C10.1', 'U2.16', 'U2.4', 'C7.1', 'R5.1', 'R6.1', 'R8.1',
'R9.1', 'R11.1', 'R14.1', 'J5.1', 'J8.1', 'JP1.1', 'D6.1', 'D7.1',
# DRV8825 sockets: M0-M2 high (1/32 step), RESET + SLEEP high (awake)
'A1.10', 'A1.11', 'A1.12', 'A1.13', 'A1.14',
'A2.10', 'A2.11', 'A2.12', 'A2.13', 'A2.14'],
# socket pin 2 is FAULT on a DRV8825 and VDD on an A4988: only bridged to 3.3V for A4988 modules
'DRV_VDD': ['A1.2', 'A2.2', 'JP1.2'],
'USB_DP': ['J2.A6', 'J2.B6', 'U5.1', 'U5.6', 'U2.5'],
'USB_DM': ['J2.A7', 'J2.B7', 'U5.3', 'U5.4', 'U2.6'],
'CC1': ['J2.A5', 'R1.1'],
'CC2': ['J2.B5', 'R2.1'],
'U0TXD': ['U1.35', 'U2.3'],
'U0RXD': ['U1.34', 'U2.2'],
'DTR': ['U2.13', 'R3.1', 'Q2.2'],
'RTS': ['U2.14', 'R4.1', 'Q1.2'],
'Q1B': ['R3.2', 'Q1.1'],
'Q2B': ['R4.2', 'Q2.1'],
'EN': ['Q1.3', 'U1.3', 'R5.2', 'C8.1', 'SW1.1'],
'IO0': ['Q2.3', 'U1.25', 'R6.2', 'SW2.1'],
'DRV_EN': ['U1.16', 'R8.2', 'A1.9', 'A2.9'],
'X_STEP': ['U1.30', 'A1.15'],
'X_DIR': ['U1.31', 'A1.16'],
'Y_STEP': ['U1.12', 'A2.15'],
'Y_DIR': ['U1.13', 'A2.16'],
# motor plugs follow the driver's own pin order (as on the Arduino CNC shield): each coil on an adjacent pair
'X_2B': ['A1.6', 'J3.1'], 'X_2A': ['A1.5', 'J3.2'], 'X_1A': ['A1.4', 'J3.3'], 'X_1B': ['A1.3', 'J3.4'],
'Y_1B': ['A2.3', 'J4.1'], 'Y_1A': ['A2.4', 'J4.2'], 'Y_2A': ['A2.5', 'J4.3'], 'Y_2B': ['A2.6', 'J4.4'],
'HOME_RAW': ['J5.2', 'R9.2', 'R10.1'],
'HOME': ['R10.2', 'C13.1', 'D6.2', 'U1.6'],
'HALL_RAW': ['J6.2', 'R11.2', 'R12.1'],
'HALL': ['R12.2', 'C14.1', 'D7.2', 'U1.7'],
'LED_DATA': ['U1.9', 'U4.2'],
'LED_BUF': ['U4.4', 'R13.1'],
'LED_DOUT': ['R13.2', 'J7.2'],
'EXP_IO32': ['U1.8', 'J8.2'],
'EXP_IO25': ['U1.10', 'J8.3'],
'STAT': ['U1.24', 'R15.1'],
'STAT_A': ['R15.2', 'D5.2'],
'PWR_A': ['R14.2', 'D4.2'],
}
# Pins intentionally left unconnected (get no-connect flags in the schematic)
NO_CONNECT = ['U1.4', 'U1.5', 'U1.14', 'U1.17', 'U1.18', 'U1.19', 'U1.20', 'U1.21', 'U1.22', 'U1.23',
'U1.11', 'U1.26', 'U1.29', 'U1.32', 'U1.33', 'U1.36', 'U1.37',
'U2.7', 'U2.8', 'U2.9', 'U2.10', 'U2.11', 'U2.12', 'U2.15',
'U1.27', 'U1.28', 'J2.A8', 'J2.B8']
# ---------------------------------------------------------------- rules
# net class -> (track width, clearance)
NETCLASS = {
'Default': (0.2, 0.16),
'Power': (0.8, 0.2),
'Motor': (0.5, 0.2),
'Rail': (0.5, 0.2),
}
NET_TO_CLASS = {}
for n in ('GND', 'VIN_RAW', 'VIN_FUSED', '+12V'):
NET_TO_CLASS[n] = 'Power'
for n in ('X_1A', 'X_1B', 'X_2A', 'X_2B', 'Y_1A', 'Y_1B', 'Y_2A', 'Y_2B'):
NET_TO_CLASS[n] = 'Motor'
for n in ('+5V', '+5V_REG', 'VBUS', '+3V3'):
NET_TO_CLASS[n] = 'Rail'
VIA = (0.6, 0.3) # diameter, drill
# PCBWay standard process: 6/6 mil track/space, 0.3 mm min hole, 0.15 mm annular ring.
# Library footprints with smaller holes (the ESP32's thermal vias are 0.2 mm) are enlarged to fit.
MIN_DRILL = 0.3
MIN_RING = 0.2
# ---------------------------------------------------------------- sourcing (PCBWay BOM)
# (refs, manufacturer, manufacturer part number, description, notes). Blank MPN = any brand to the description.
SOURCING = [
(['U1'], 'Espressif', 'ESP32-WROOM-32E-N8', 'Wi-Fi + Bluetooth module, 8 MB flash, PCB antenna', ''),
(['U2'], 'WCH', 'CH340C', 'USB to UART bridge, SOIC-16', ''),
(['U3'], 'Diodes Incorporated', 'AP7361C-33E-13', 'LDO regulator 3.3 V 1 A, low dropout, SOT-223 (pin 1 IN, 2 GND/tab, 3 OUT)', 'Not an AMS1117: the pinout differs, no substitutes'),
(['U5'], 'STMicroelectronics', 'USBLC6-2SC6', 'USB ESD protection, 2 lines, SOT-23-6', ''),
(['U4'], 'Texas Instruments', 'SN74AHCT1G125DBVR', 'Single buffer, TTL-level input, SOT-23-5', ''),
(['PS1'], 'RECOM', 'R-78E5.0-1.0', 'Switching regulator 5 V 1 A, SIP-3', ''),
(['F1'], 'Bourns', 'MF-MSMF250/16X-2', 'PTC resettable fuse, 2.5 A hold, 5 A trip, 16 V, 1812', ''),
(['J1'], 'Same Sky (CUI Devices)', 'PJ-063AH', 'DC barrel jack, 2.0 mm pin, fits 5.5 x 2.1 mm plugs', ''),
(['J2'], 'Korean Hroparts Elec', 'TYPE-C-31-M-12', 'USB-C receptacle, USB 2.0, 16 pin', ''),
(['J3', 'J4'], 'JST', 'B4B-XH-A(LF)(SN)', 'XH header, 4 pin, 2.5 mm pitch, vertical', ''),
(['J5', 'J6', 'J7'], 'JST', 'B3B-XH-A(LF)(SN)', 'XH header, 3 pin, 2.5 mm pitch, vertical', ''),
(['J8'], 'Sullins', 'PRPC004SAAN-RC', 'Male pin header 1 x 4, 2.54 mm pitch, vertical', ''),
(['A1', 'A2'], 'Sullins', 'PPTC081LFBN-RC', 'Female pin header 1 x 8, 2.54 mm pitch, 8.5 mm tall', '2 headers per driver (4 total). Sockets only: the DRV8825 modules are supplied by me'),
(['D1', 'D2'], '', 'SS34', 'Schottky diode 40 V 3 A, SMA', 'Any brand'),
(['D3'], '', 'B5819W', 'Schottky diode 40 V 1 A, SOD-123', 'Any brand'),
(['D6', 'D7'], '', 'BAT54WS', 'Schottky diode 30 V 0.2 A, SOD-323', 'Any brand'),
(['D4'], '', '', 'LED green, 0603', 'Any brand'),
(['D5'], '', '', 'LED blue, 0603', 'Any brand'),
(['Q1', 'Q2'], 'onsemi', 'MMBT3904LT1G', 'NPN transistor 40 V 200 mA, SOT-23', 'Any MMBT3904 ok'),
(['SW1', 'SW2'], 'Omron', 'B3U-1000P', 'Tactile switch, SMD, 3 x 2.5 mm', ''),
(['C1', 'C2'], 'Panasonic', 'EEE-FK1V101GP', 'Aluminium electrolytic capacitor 100 uF 35 V, SMD 8 x 10.2 mm', ''),
(['C3', 'C4'], '', '', 'Ceramic capacitor 10 uF 25 V X5R, 1206', 'Any brand'),
(['C16', 'C17'], '', '', 'Ceramic capacitor 10 uF 50 V X7R, 1206', 'Any brand'),
(['C5', 'C9'], '', '', 'Ceramic capacitor 10 uF 16 V X5R, 0805', 'Any brand'),
(['C6'], '', '', 'Ceramic capacitor 22 uF 10 V X5R, 0805', 'Any brand'),
(['C7', 'C10', 'C13', 'C14', 'C15'], '', '', 'Ceramic capacitor 100 nF 50 V X7R, 0603', 'Any brand'),
(['C8'], '', '', 'Ceramic capacitor 1 uF 16 V X7R, 0603', 'Any brand'),
(['R1', 'R2'], '', '', 'Resistor 5.1 k 1%, 0603', 'Any brand'),
(['R3', 'R4', 'R5', 'R6', 'R8', 'R9', 'R11'], '', '', 'Resistor 10 k 1%, 0603', 'Any brand'),
(['R10', 'R12', 'R14', 'R15'], '', '', 'Resistor 1 k 1%, 0603', 'Any brand'),
(['R13'], '', '', 'Resistor 330 1%, 0603', 'Any brand'),
]
SOURCING_QTY = {('A1', 'A2'): 4} # BOM lines whose piece count differs from the designator count
+219
View File
@@ -0,0 +1,219 @@
"""Build the PCBWay order files and copy the KiCad project + outputs into the repo.
Run after route.py and sch.py. Writes to ~/kicad/sandtable-controller/fab (the KiCad snap can't
read inside ~/snap), then copies the results to ../ (the repo's pcb/ folder).
"""
import csv
import glob
import os
import shutil
import subprocess
import sys
import zipfile
sys.path.insert(0, os.path.dirname(__file__))
import design as D
WORK = os.path.expanduser('~/kicad/sandtable-controller')
NAME = 'sandtable-controller'
PCB = os.path.join(WORK, NAME + '.kicad_pcb')
SCH = os.path.join(WORK, NAME + '.kicad_sch')
FAB = os.path.join(WORK, 'fab')
GERB = os.path.join(FAB, 'gerbers')
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
CLI = 'kicad.kicad-cli'
def cli(*args):
r = subprocess.run([CLI, *args], capture_output=True, text=True)
if r.returncode != 0:
sys.exit(f'kicad-cli {" ".join(args[:3])} failed:\n{r.stdout}\n{r.stderr}')
def bom(pcb_text):
"""PCBWay BOM straight from design.py (one line per sourcing group)."""
parts = {r: p for r, p in D.PARTS.items() if not r.startswith(('H', 'JP'))} # holes, solder jumpers
covered = [r for refs, *_ in D.SOURCING for r in refs]
dup = sorted({r for r in covered if covered.count(r) > 1})
missing = sorted(set(parts) - set(covered))
extra = sorted(set(covered) - set(parts))
if dup or missing or extra:
sys.exit(f'SOURCING mismatch: duplicate {dup}, missing {missing}, unknown {extra}')
rows = []
for i, (refs, mfr, mpn, desc, notes) in enumerate(D.SOURCING, 1):
fps = {parts[r][1].split(':')[1] for r in refs}
tht = any(pcb_text.count(f'(footprint "{parts[r][1]}"') and _is_tht(pcb_text, r) for r in refs)
qty = D.SOURCING_QTY.get(tuple(refs), len(refs))
rows.append([i, ','.join(refs), qty, mfr, mpn, desc, ' / '.join(sorted(fps)),
'Thru-Hole' if tht else 'SMD', notes])
return rows
def _is_tht(pcb_text, ref):
# find the footprint block that carries this reference and read its (attr ...)
key = f'(property "Reference" "{ref}"'
i = pcb_text.find(key)
start = pcb_text.rfind('(footprint ', 0, i)
j = pcb_text.find('(attr ', start, i + 4000)
return pcb_text[j:j + 30].startswith('(attr through_hole')
def main():
os.makedirs(GERB, exist_ok=True)
for f in glob.glob(os.path.join(GERB, '*')):
os.remove(f)
# Gerbers + drill, all relative to the board's bottom-left corner
cli('pcb', 'export', 'gerbers', '-o', GERB + '/', '-l',
'F.Cu,B.Cu,F.Paste,F.SilkS,B.SilkS,F.Mask,B.Mask,Edge.Cuts', '--use-drill-file-origin', PCB)
cli('pcb', 'export', 'drill', '-o', GERB + '/', '--format', 'excellon', '--excellon-units', 'mm',
'--excellon-separate-th', '--drill-origin', 'plot', PCB)
zpath = os.path.join(FAB, NAME + '-gerbers.zip')
with zipfile.ZipFile(zpath, 'w', zipfile.ZIP_DEFLATED) as z:
for f in sorted(os.listdir(GERB)):
z.write(os.path.join(GERB, f), f)
print('gerbers:', ', '.join(sorted(os.listdir(GERB))))
# Centroid (pick and place)
raw = os.path.join(FAB, 'pos-raw.csv')
cli('pcb', 'export', 'pos', '-o', raw, '--format', 'csv', '--units', 'mm', '--side', 'both',
'--use-drill-file-origin', PCB)
pos = list(csv.DictReader(open(raw)))
os.remove(raw)
with open(os.path.join(FAB, 'centroid-pcbway.csv'), 'w', newline='') as f:
w = csv.writer(f)
w.writerow(['Designator', 'Mid X (mm)', 'Mid Y (mm)', 'Layer', 'Rotation', 'Value', 'Footprint'])
for r in sorted(pos, key=lambda r: (r['Ref'][0], int(''.join(c for c in r['Ref'] if c.isdigit())))):
w.writerow([r['Ref'], f"{float(r['PosX']):.3f}", f"{float(r['PosY']):.3f}",
'Top' if r['Side'] == 'top' else 'Bottom', f"{float(r['Rot']) % 360:g}", r['Val'], r['Package']])
print('centroid:', len(pos), 'parts')
# BOM
pcb_text = open(PCB).read()
rows = bom(pcb_text)
with open(os.path.join(FAB, 'bom-pcbway.csv'), 'w', newline='') as f:
w = csv.writer(f)
w.writerow(['Item #', 'Designator', 'Qty', 'Manufacturer', 'Mfg Part #', 'Description / Value',
'Package / Footprint', 'Type', 'Notes'])
w.writerows(rows)
print('bom:', len(rows), 'lines,', sum(r[2] for r in rows), 'pieces')
# 3D model, renders, schematic PDF
cli('pcb', 'export', 'step', '-f', '-o', os.path.join(FAB, NAME + '.step'), '--subst-models', PCB)
cli('pcb', 'render', '-o', os.path.join(FAB, 'render-iso.png'), '--width', '1800', '--height', '1200',
'--quality', 'high', '--perspective', '--floor', '--rotate', '310,0,325', '--zoom', '0.9', PCB)
for side in ('top', 'bottom'):
cli('pcb', 'render', '-o', os.path.join(FAB, f'render-{side}.png'), '--side', side,
'--width', '1800', '--height', '1400', '--quality', 'high', PCB)
cli('sch', 'export', 'pdf', '-o', os.path.join(WORK, NAME + '-schematic.pdf'), SCH)
# Assembly drawing (part outlines, references, polarity marks)
assembly_pdf(os.path.join(FAB, 'assembly-top.pdf'))
# Copy into the repo: KiCad project, the PCBWay order folder, renders
kdir = os.path.join(REPO, 'kicad')
odir = os.path.join(REPO, 'pcbway-order')
rdir = os.path.join(REPO, 'renders')
for d in (kdir, odir, rdir):
os.makedirs(d, exist_ok=True)
for f in glob.glob(os.path.join(odir, '*')):
os.remove(f)
for f in (NAME + '.kicad_pro', NAME + '.kicad_pcb', NAME + '.kicad_sch', 'sym-lib-table', 'fp-lib-table',
'sandtable.kicad_sym'):
shutil.copy2(os.path.join(WORK, f), kdir)
tag = f'{NAME}-v{D.VERSION}'
shutil.copy2(zpath, os.path.join(odir, f'1-gerbers-{tag}.zip'))
shutil.copy2(os.path.join(FAB, 'bom-pcbway.csv'), os.path.join(odir, f'2-bom-{tag}.csv'))
shutil.copy2(os.path.join(FAB, 'centroid-pcbway.csv'), os.path.join(odir, f'3-centroid-{tag}.csv'))
shutil.copy2(os.path.join(FAB, 'assembly-top.pdf'), os.path.join(odir, f'assembly-top-v{D.VERSION}.pdf'))
shutil.copy2(os.path.join(WORK, NAME + '-schematic.pdf'), os.path.join(odir, f'schematic-v{D.VERSION}.pdf'))
write_order_sheet(os.path.join(odir, 'ORDER.md'), tag, rows, pcb_text)
for f in ('render-iso.png', 'render-top.png', 'render-bottom.png'):
shutil.copy2(os.path.join(FAB, f), rdir)
shutil.copy2(os.path.join(FAB, NAME + '.step'), REPO)
shutil.copy2(os.path.join(WORK, NAME + '-schematic.pdf'), REPO)
print('order folder:', ', '.join(sorted(os.listdir(odir))))
def assembly_pdf(out):
"""KiCad plots on an A4 sheet with the board small in one corner: crop to the board and fit to A4."""
full, crop = out + '.full.pdf', out + '.crop.pdf'
cli('pcb', 'export', 'pdf', '-o', full, '--mode-single', '-l', 'F.Fab,F.SilkS,Edge.Cuts',
'--ev', '--black-and-white', PCB)
mm, page_h, m = 72 / 25.4, 210.0, 5.0 # A4 landscape sheet, 5 mm margin
ox, oy = D.ORIGIN
box = [(ox - m) * mm, (page_h - (oy + D.BOARD_H + m)) * mm, (ox + D.BOARD_W + m) * mm, (page_h - (oy - m)) * mm]
gs = ['gs', '-q', '-sDEVICE=pdfwrite']
subprocess.run(gs + ['-o', crop, '-c', '[/CropBox [' + ' '.join(f'{v:.1f}' for v in box) + '] /PAGES pdfmark',
'-f', full], check=True)
subprocess.run(gs + ['-o', out, '-dUseCropBox', '-dFIXEDMEDIA', '-dPDFFitPage',
'-dDEVICEWIDTHPOINTS=842', '-dDEVICEHEIGHTPOINTS=595', crop], check=True)
os.remove(full)
os.remove(crop)
def write_order_sheet(path, tag, rows, pcb_text):
smd = pcb_text.count('(attr smd')
tht_pieces = sum(r[2] for r in rows if r[7] == 'Thru-Hole')
tht_parts = sum(len(r[1].split(',')) for r in rows if r[7] == 'Thru-Hole')
text = f"""# PCBWay order: SandTable Controller v{D.VERSION}
## Files
| File | Where it goes |
|---|---|
| `1-gerbers-{tag}.zip` | PCB order: **Upload Gerber file** |
| `2-bom-{tag}.csv` | Assembly order: **BOM file** |
| `3-centroid-{tag}.csv` | Assembly order: **Centroid / pick-and-place file** |
| `assembly-top-v{D.VERSION}.pdf` | Attach if PCBWay asks for an assembly drawing |
| `schematic-v{D.VERSION}.pdf` | Attach if PCBWay asks for a schematic |
## PCB settings
| Setting | Value |
|---|---|
| Board type | Single pieces |
| Size | {D.BOARD_W:g} x {D.BOARD_H:g} mm |
| Quantity | 5 (the minimum) |
| Layers | 2 |
| Material | FR-4, TG130-140 |
| Thickness | 1.6 mm |
| Min track / spacing | 6/6 mil |
| Min hole size | 0.3 mm |
| Solder mask / silkscreen | any colour / white |
| Surface finish | HASL lead-free (or ENIG) |
| Via process | Tenting vias |
| Finished copper | 1 oz |
| Product No. | Specify a location (the back of the board has a "WayWayWay" marker for it) |
## Assembly settings (PCB Assembly quote)
| Setting | Value |
|---|---|
| Service | Turnkey (PCBWay buys the parts) |
| Assembly side | Top side only |
| Quantity | 1 or 2 (fewer than the 5 bare boards is fine) |
| Unique parts | {len(rows)} |
| SMD parts | {smd} |
| BGA / QFP parts | 0 |
| Through-hole parts | {tht_pieces} ({tht_parts} designators; each driver socket is 2 headers) |
| Accept alternatives | Yes, for lines marked "Any brand" |
**Note for PCBWay** (paste into the comments box):
> Please fit the female header sockets for A1 and A2 only. Do not supply the stepper driver modules; I will plug in my own. BOM lines marked "Any brand" may use any equivalent part to the description. U3 (AP7361C-33E) has a different pinout from AMS1117-type regulators, so please do not substitute it. Leave solder jumper JP1 open. C16 and C17 sit between the driver socket strips, under where the modules plug in; that is intended. Centroid coordinates are in mm, with the origin at the board's bottom-left corner.
## When PCBWay sends their review
- Check the polarity of C1, C2 (electrolytic), D1-D3, D6, D7 (diodes) and D4, D5 (LEDs) against the + and bar marks in `assembly-top-v{D.VERSION}.pdf`.
- Check that the ESP32 (U1) antenna end sits at the board edge, over the copper-free area.
- Check that pin 1 of U2, U3, U4 and U5 is at the dot or triangle on the silkscreen.
- U3 is an AP7361C-33E, not an AMS1117: its pinout is different (pin 1 IN, pin 2 GND/tab, pin 3 OUT), so it must not be swapped for a 1117-type regulator.
- JP1 is a solder jumper (bare pads), not a part: leave it open.
"""
with open(path, 'w') as f:
f.write(text)
if __name__ == '__main__':
main()
+184
View File
@@ -0,0 +1,184 @@
"""Load footprints and symbols from the installed KiCad libraries."""
import math
import os
from sexpr import parse, find, findall, QStr
KICAD = os.environ.get('KICAD_SHARE', '/snap/kicad/current/usr/share/kicad')
FP_DIR = os.path.join(KICAD, 'footprints')
SYM_DIR = os.path.join(KICAD, 'symbols')
def load_footprint(lib_id, min_drill=None, min_ring=0.2, solid_thermal_pads=False):
lib, name = lib_id.split(':', 1)
path = os.path.join(FP_DIR, lib + '.pretty', name + '.kicad_mod')
with open(path) as f:
fp = parse(f.read())
if solid_thermal_pads:
_solidify_thermal_pads(fp)
if min_drill:
_enforce_min_drill(fp, min_drill, min_ring)
return fp
def _solidify_thermal_pads(fp):
"""Remove plated holes that sit inside a cluster of same-numbered SMD pads (thermal vias in an
exposed pad) and add one copper-only pad over the cluster, so the copper stays continuous under
the solder-mask/paste windows but no hole is left in a pad. Fabs flag holes in pads (solder
wicks into them) and charge for resin filling."""
import copy
pads = findall(fp, 'pad')
by_num = {}
for p in pads:
by_num.setdefault(str(p[1]), []).append(p)
for num, group in by_num.items():
smd = [p for p in group if str(p[2]) == 'smd']
tht = [p for p in group if str(p[2]) == 'thru_hole']
if len(smd) < 2 or not tht:
continue
boxes = []
for p in smd:
at, size = find(p, 'at'), find(p, 'size')
x, y, w, h = float(at[1]), float(at[2]), float(size[1]), float(size[2])
boxes.append((x - w / 2, y - h / 2, x + w / 2, y + h / 2))
x0, y0 = min(b[0] for b in boxes), min(b[1] for b in boxes)
x1, y1 = max(b[2] for b in boxes), max(b[3] for b in boxes)
inside = [p for p in tht if x0 - 0.5 <= float(find(p, 'at')[1]) <= x1 + 0.5
and y0 - 0.5 <= float(find(p, 'at')[2]) <= y1 + 0.5]
if not inside:
continue
for p in inside:
fp.remove(p)
solid = copy.deepcopy(smd[0])
solid[:] = [v for v in solid if not (isinstance(v, list) and v and v[0] in ('uuid', 'roundrect_rratio'))]
solid[3] = 'rect'
at, size, layers = find(solid, 'at'), find(solid, 'size'), find(solid, 'layers')
at[1:] = [f'{(x0 + x1) / 2:g}', f'{(y0 + y1) / 2:g}']
size[1:] = [f'{x1 - x0:g}', f'{y1 - y0:g}']
layers[1:] = [QStr('F.Cu')]
fp.insert(fp.index(smd[0]), solid)
def _enforce_min_drill(fp, min_drill, min_ring):
"""Grow round plated holes below min_drill (and their copper ring) so the board fits the fab's process."""
for p in findall(fp, 'pad'):
if str(p[2]) != 'thru_hole':
continue
drill = find(p, 'drill')
if not drill or len(drill) < 2 or isinstance(drill[1], list) or str(drill[1]) == 'oval':
continue
if float(drill[1]) >= min_drill - 1e-9:
continue
drill[1] = f'{min_drill:g}'
size = find(p, 'size')
need = min_drill + 2 * min_ring
for i in (1, 2):
if float(size[i]) < need:
size[i] = f'{need:g}'
def fp_pads(fp):
"""Pads as dicts in footprint-local coords (KiCad: y down)."""
pads = []
for p in findall(fp, 'pad'):
num = str(p[1])
kind = p[2] # smd / thru_hole / np_thru_hole / connect
shape = p[3]
at = find(p, 'at')
x, y = float(at[1]), float(at[2])
rot = float(at[3]) if len(at) > 3 else 0.0
size = find(p, 'size')
w, h = float(size[1]), float(size[2])
layers = [str(l) for l in find(p, 'layers')[1:]]
drill = find(p, 'drill')
d = None
if drill:
vals = [v for v in drill[1:] if not isinstance(v, list)]
if vals and vals[0] == 'oval':
d = (float(vals[1]), float(vals[2]) if len(vals) > 2 else float(vals[1]))
elif vals:
d = (float(vals[0]), float(vals[0]))
pads.append(dict(num=num, kind=kind, shape=shape, x=x, y=y, rot=rot,
w=w, h=h, layers=layers, drill=d, node=p))
return pads
def fp_courtyard(fp):
"""Bounding box (x0,y0,x1,y1) of the front/back courtyard lines/rects."""
xs, ys = [], []
for kind in ('fp_line', 'fp_rect', 'fp_circle', 'fp_arc', 'fp_poly'):
for g in findall(fp, kind):
layer = find(g, 'layer')
if not layer or 'CrtYd' not in str(layer[1]):
continue
for key in ('start', 'end', 'mid', 'center'):
pt = find(g, key)
if pt:
xs.append(float(pt[1])); ys.append(float(pt[2]))
pts = find(g, 'pts')
if pts:
for xy in findall(pts, 'xy'):
xs.append(float(xy[1])); ys.append(float(xy[2]))
if kind == 'fp_circle':
c = find(g, 'center'); e = find(g, 'end')
r = math.hypot(float(e[1]) - float(c[1]), float(e[2]) - float(c[2]))
xs += [float(c[1]) - r, float(c[1]) + r]
ys += [float(c[2]) - r, float(c[2]) + r]
if not xs:
return None
return (min(xs), min(ys), max(xs), max(ys))
_symlib_cache = {}
def load_symbol(lib_id):
"""Return the symbol definition list (with 'extends' resolved)."""
lib, name = lib_id.split(':', 1)
if lib not in _symlib_cache:
with open(os.path.join(SYM_DIR, lib + '.kicad_sym')) as f:
_symlib_cache[lib] = parse(f.read())
root = _symlib_cache[lib]
syms = {str(s[1]): s for s in findall(root, 'symbol')}
s = syms[name]
ext = find(s, 'extends')
if ext:
base = load_symbol(lib + ':' + str(ext[1]))
# copy base, take name + properties from derived
import copy
merged = copy.deepcopy(base)
merged[1] = QStr(name)
# rename sub-units
for sub in findall(merged, 'symbol'):
sub[1] = QStr(str(sub[1]).replace(str(ext[1]), name, 1))
# properties: inherited from the base, overridden/extended by the derived symbol
base_props = findall(merged, 'property')
merged = [x for x in merged if not (isinstance(x, list) and x and x[0] == 'property')]
derived = {str(p[1]): p for p in findall(s, 'property')}
props = []
for p in base_props:
props.append(derived.pop(str(p[1]), p))
props += list(derived.values())
# insert properties after the symbol-level flags
idx = 2
while idx < len(merged) and isinstance(merged[idx], list) and merged[idx][0] in ('pin_names', 'pin_numbers', 'exclude_from_sim', 'in_bom', 'on_board'):
idx += 1
merged = merged[:idx] + props + merged[idx:]
return merged
return s
def sym_pins(sym):
"""Pins (number, name, type, x, y, angle, length) in symbol coords (y up)."""
pins = []
for unit in findall(sym, 'symbol'):
for p in findall(unit, 'pin'):
at = find(p, 'at')
length = float(find(p, 'length')[1])
name = find(p, 'name')
number = find(p, 'number')
pins.append(dict(type=str(p[1]), style=str(p[2]),
x=float(at[1]), y=float(at[2]), angle=float(at[3]) if len(at) > 3 else 0.0,
length=length, name=str(name[1]), num=str(number[1]),
hidden=('hide' in [str(v) for v in p if not isinstance(v, list)]),
unit=str(unit[1])))
return pins
+21
View File
@@ -0,0 +1,21 @@
"""Build the board, report placement problems and write an unrouted PCB for a look."""
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
import design as D
from board import build, check_placement, write_pcb, write_project, keepout_zone
OUT = os.path.expanduser('~/kicad/sandtable-controller')
parts = build()
probs = check_placement(parts)
print(f'{len(parts)} parts, {sum(len(v) for v in D.NETS.values())} net pins, {len(D.NETS)} nets')
for p in probs:
print(' !', p)
if not probs:
print(' placement OK')
zones = [keepout_zone('ANTENNA', 18.75, 56.9, 67.25, D.BOARD_H)]
write_pcb(os.path.join(OUT, 'sandtable-controller.kicad_pcb'), parts, zones=zones)
write_project(os.path.join(OUT, 'sandtable-controller.kicad_pro'), 'sandtable-controller')
print('wrote unrouted board')
+67
View File
@@ -0,0 +1,67 @@
"""Find free spots for parts added in a revision: a spiral search around each part's
preferred position (from design.py), trying 4 rotations, keeping courtyards apart,
inside the board and out of the antenna keep-out. Prints the positions to paste into design.py."""
import math
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
import design as D
from board import build
GAP = 0.25 # min gap between courtyards, mm
KEEPOUT = (18.75, 56.9, 67.25, D.BOARD_H) # ESP32 antenna
EDGE = 0.6
def overlaps(a, b, gap=GAP):
return min(a[2], b[2]) - max(a[0], b[0]) > -gap and min(a[3], b[3]) - max(a[1], b[1]) > -gap
def legal(part, fixed):
for r in part.court_rects():
if r[0] < EDGE or r[1] < EDGE or r[2] > D.BOARD_W - EDGE or r[3] > D.BOARD_H - EDGE:
return False
if overlaps(r, KEEPOUT, 0):
return False
if any(overlaps(r, f) for f in fixed):
return False
return True
def place(refs, parts):
fixed = [r for ref, p in parts.items() if ref not in refs for r in p.court_rects()]
out = {}
for ref in refs:
p = parts[ref]
x0, y0, rot0 = p.x, p.y, p.rot
best = None
for step in range(0, 80): # radius in 0.25 mm steps, up to 20 mm
rad = step * 0.25
n = max(1, int(2 * math.pi * rad / 0.25))
for i in range(n):
a = 2 * math.pi * i / n
x, y = round(x0 + rad * math.cos(a), 2), round(y0 + rad * math.sin(a), 2)
for rot in (rot0, (rot0 + 90) % 360, (rot0 + 180) % 360, (rot0 + 270) % 360):
p.x, p.y, p.rot = x, y, rot
if legal(p, fixed):
best = (x, y, rot, rad)
break
if best:
break
if best:
break
if not best:
print(f'{ref}: no spot within 20 mm')
p.x, p.y, p.rot = x0, y0, rot0
continue
p.x, p.y, p.rot = best[:3]
fixed += p.court_rects()
out[ref] = best
print(f'{ref}: ({best[0]}, {best[1]}) rot {best[2]} moved {best[3]:.2f} mm from preferred')
return out
if __name__ == '__main__':
parts = build()
place(sys.argv[1:], parts)
+213
View File
@@ -0,0 +1,213 @@
"""Ground pour: raster fill on the router grid -> fractured polygons for KiCad.
Cells are squares of side G centred on grid points. A cell may be filled when no
foreign copper is within the pour clearance + half the cell diagonal; that radius
map already exists in the router (radius for a 0.2 mm track = 0.38 mm).
"""
from collections import deque
import router as R
def fill_mask(rt, net, layer, r_map, open_cells=2):
k = rt.nid(net)
nx, ny = rt.nx, rt.ny
m = rt.maps[r_map][layer]
mask = bytearray(nx * ny)
for i in range(nx * ny):
v = m[i]
if v == -1 or v == k:
mask[i] = 1
# morphological opening (erode then dilate, 4-neighbourhood) removes slivers
for _ in range(open_cells):
mask = _erode(mask, nx, ny)
for _ in range(open_cells):
mask = _dilate(mask, nx, ny, limit=m, k=k)
return mask
def _erode(mask, nx, ny):
out = bytearray(len(mask))
for iy in range(1, ny - 1):
b = iy * nx
for ix in range(1, nx - 1):
i = b + ix
if mask[i] and mask[i - 1] and mask[i + 1] and mask[i - nx] and mask[i + nx]:
out[i] = 1
return out
def _dilate(mask, nx, ny, limit, k):
out = bytearray(mask)
for iy in range(1, ny - 1):
b = iy * nx
for ix in range(1, nx - 1):
i = b + ix
if not mask[i] and (mask[i - 1] or mask[i + 1] or mask[i - nx] or mask[i + nx]):
v = limit[i]
if v == -1 or v == k:
out[i] = 1
return out
def keep_connected(mask, nx, ny, anchors):
"""Keep only 4-connected components that contain an anchor cell index."""
keep = bytearray(len(mask))
q = deque(i for i in anchors if mask[i])
for i in q:
keep[i] = 1
while q:
i = q.popleft()
for j in (i - 1, i + 1, i - nx, i + nx):
if 0 <= j < len(mask) and mask[j] and not keep[j]:
keep[j] = 1
q.append(j)
return keep
def contours(mask, nx, ny):
"""Boundary loops of the union of filled cells, in corner coordinates (cx, cy)
where cell (ix, iy) spans corners (ix..ix+1, iy..iy+1). Filled area on the left."""
edges = {}
def add(a, b):
edges.setdefault(a, []).append(b)
for iy in range(ny):
b = iy * nx
for ix in range(nx):
if not mask[b + ix]:
continue
# neighbours (outside = empty)
up = iy > 0 and mask[b + ix - nx]
dn = iy < ny - 1 and mask[b + ix + nx]
lf = ix > 0 and mask[b + ix - 1]
rt_ = ix < nx - 1 and mask[b + ix + 1]
# traverse cell boundary counter-clockwise in screen coords (y down): filled on left
if not up:
add((ix + 1, iy), (ix, iy))
if not lf:
add((ix, iy), (ix, iy + 1))
if not dn:
add((ix, iy + 1), (ix + 1, iy + 1))
if not rt_:
add((ix + 1, iy + 1), (ix + 1, iy))
loops = []
while edges:
start = next(iter(edges))
loop = [start]
cur = start
prev_dir = None
while True:
outs = edges[cur]
if len(outs) == 1:
nxt = outs.pop()
else:
# pinch point: prefer the turn that keeps the filled region on the left (turn left first)
best = None
for cand in outs:
d = (cand[0] - cur[0], cand[1] - cur[1])
if prev_dir is None:
best = cand
break
# left turn in y-down coords: (dx,dy) -> (dy,-dx)
if d == (prev_dir[1], -prev_dir[0]):
best = cand
break
if best is None:
best = outs[0]
outs.remove(best)
nxt = best
if not outs:
del edges[cur]
prev_dir = (nxt[0] - cur[0], nxt[1] - cur[1])
cur = nxt
if cur == start:
break
loop.append(cur)
loops.append(_simplify(loop))
return loops
def _simplify(loop):
out = []
n = len(loop)
for i in range(n):
a, b, c = loop[i - 1], loop[i], loop[(i + 1) % n]
if (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]) != 0:
out.append(b)
return out
def area2(loop):
s = 0
for i in range(len(loop)):
x1, y1 = loop[i - 1]
x2, y2 = loop[i]
s += x1 * y2 - x2 * y1
return s
def point_in(loop, x, y):
inside = False
n = len(loop)
for i in range(n):
x1, y1 = loop[i - 1]
x2, y2 = loop[i]
if (y1 > y) != (y2 > y):
xi = x1 + (y - y1) * (x2 - x1) / (y2 - y1)
if xi > x:
inside = not inside
return inside
def fracture(outer, holes):
"""Merge holes into the outer ring with zero-width bridges (KiCad's fractured form).
Rectilinear integer loops. Holes are bridged rightwards to the nearest vertical edge."""
ring = list(outer)
for hole in sorted(holes, key=lambda h: -max(p[0] for p in h)):
xh = max(p[0] for p in hole)
vi = min((i for i in range(len(hole)) if hole[i][0] == xh), key=lambda i: hole[i][1])
V = hole[vi]
best = None
n = len(ring)
for i in range(n):
A, B = ring[i], ring[(i + 1) % n]
if A[0] == B[0] and A[0] > V[0] and min(A[1], B[1]) <= V[1] <= max(A[1], B[1]):
if best is None or A[0] < best[0]:
best = (A[0], i)
if best is None:
continue
x, i = best
P = (x, V[1])
seq = hole[vi:] + hole[:vi]
ring = ring[:i + 1] + [P] + seq + [V, P] + ring[i + 1:]
out = []
for p in ring:
if not out or out[-1] != p:
out.append(p)
if len(out) > 1 and out[0] == out[-1]:
out.pop()
return out
def polygons(mask, nx, ny):
loops = contours(mask, nx, ny)
outers, holes = [], []
for lp in loops:
if len(lp) < 4:
continue
(outers if area2(lp) < 0 else holes).append(lp)
result = []
owner = {}
for h in holes:
x, y = h[0][0] + 1e-3, h[0][1] + 1e-3
cands = [o for o in outers if point_in(o, x, y)]
if cands:
owner[id(h)] = min(cands, key=lambda o: abs(area2(o)))
for o in outers:
hs = [h for h in holes if owner.get(id(h)) is o]
result.append(fracture(o, hs))
return result
def to_mm(poly, G):
return [((cx - 0.5) * G, (cy - 0.5) * G) for (cx, cy) in poly]
+349
View File
@@ -0,0 +1,349 @@
"""Place + route the SandTable controller and write the KiCad board."""
import math
import os
import sys
import time
sys.path.insert(0, os.path.dirname(__file__))
import design as D
from board import build, check_placement, write_pcb, write_project, keepout_zone, text_node
import router as R
OUT = os.path.expanduser('~/kicad/sandtable-controller')
# routing width per net
WIDTH = {}
for n in D.NETS:
WIDTH[n] = 0.2
for n in ('VIN_RAW', 'VIN_FUSED', '+12V'):
WIDTH[n] = 0.8
WIDTH['GND'] = 0.5
for n in ('X_1A', 'X_1B', 'X_2A', 'X_2B', 'Y_1A', 'Y_1B', 'Y_2A', 'Y_2B'):
WIDTH[n] = 0.5
for n in ('VBUS', '+3V3'):
WIDTH[n] = 0.4
for n in ('+5V', '+5V_REG'): # up to 1 A from the 5 V regulator (LED strip)
WIDTH[n] = 0.6
FALLBACK = [0.3, 0.2]
# nets that must keep their full width: fail loudly instead of necking down
NO_FALLBACK = {'VIN_RAW', 'VIN_FUSED', '+12V', 'X_1A', 'X_1B', 'X_2A', 'X_2B', 'Y_1A', 'Y_1B', 'Y_2A', 'Y_2B'}
VIA_PAD_GAP = 0.2 # min copper gap between a via and any pad (keeps holes out of solder pads)
def radius(w):
return round(w / 2 + R.CLEAR + R.MARGIN, 3)
def via_radius(w):
return max(radius(w), round(R.VIA_D / 2 + R.CLEAR + R.MARGIN, 3))
# Hand-routed USB-C escape (pads are on a 0.5 mm pitch). Board coords.
USB_X = 7.445
PREROUTE = {
'USB_DP': dict(tracks=[(USB_X, 43.75, 9.0, 43.75, 0.2, 0), (USB_X, 44.75, 9.0, 44.75, 0.2, 0),
(9.0, 43.75, 9.0, 44.75, 0.2, 0)], ports=[(9.0, 44.25, (0,))]),
'USB_DM': dict(tracks=[(USB_X, 43.25, 6.15, 43.25, 0.2, 0), (USB_X, 44.25, 6.15, 44.25, 0.2, 0),
(6.15, 43.25, 6.15, 44.25, 0.2, 1)],
vias=[(6.15, 43.25), (6.15, 44.25)], ports=[(6.15, 43.75, (1,))]),
'CC1': dict(tracks=[(USB_X, 42.75, 10.675, 42.75, 0.2, 0)]),
'CC2': dict(tracks=[(USB_X, 45.75, 10.675, 45.75, 0.2, 0)]),
'GND': dict(tracks=[(USB_X, 40.75, 9.3, 40.75, 0.4, 0), (USB_X, 47.25, 9.3, 47.25, 0.4, 0),
# driver VMOT ceramics (C16, C17): straight down from the socket's GND pin 7
(22.86, 1.9, 22.86, 5.4, 0.5, 0), (43.86, 1.9, 43.86, 5.4, 0.5, 0),
# boxed-in GND pads get their own via: U5 pin 2 (between the USB lines), C13/C14
(11.863, 49.5, 10.55, 49.5, 0.3, 0),
(61.775, 18.5, 61.775, 19.6, 0.3, 0), (61.775, 19.6, 61.775, 20.7, 0.3, 0)],
vias=[(10.55, 49.5), (61.775, 19.6)],
ports=[(9.3, 40.75, (0,)), (9.3, 47.25, (0,))]),
# ... and from the socket's VMOT pin 8
'+12V': dict(tracks=[(20.32, 1.9, 20.32, 5.4, 0.8, 0), (41.32, 1.9, 41.32, 5.4, 0.8, 0)]),
'VBUS': dict(tracks=[(USB_X, 41.55, 9.3, 41.55, 0.4, 0), (USB_X, 46.45, 9.3, 46.45, 0.4, 0)],
ports=[(9.3, 41.55, (0,)), (9.3, 46.45, (0,))]),
}
ORDER_FIRST = ['USB_DP', 'USB_DM', 'CC1', 'CC2', 'VBUS', 'VIN_RAW',
# motor coils first, outermost lane first (J3.1 / J3.4 / J4.1 / J4.4), then the 12 V trunk
'X_2B', 'X_1B', 'X_2A', 'X_1A', 'Y_1B', 'Y_2B', 'Y_1A', 'Y_2A', '+12V',
'STAT', # short straight run under the LEDs; boxed in if left late
'+5V_REG', '+5V', '+3V3']
ORDER_LAST = ['GND']
def net_order(parts):
pads_by_net = {}
for p in parts.values():
for pad in p.pads():
if pad.net:
pads_by_net.setdefault(pad.net, []).append(pad)
rest = [n for n in D.NETS if n not in ORDER_FIRST and n not in ORDER_LAST]
def span(n):
xs = [p.x for p in pads_by_net[n]]
ys = [p.y for p in pads_by_net[n]]
return (max(xs) - min(xs)) + (max(ys) - min(ys))
rest.sort(key=span)
return ORDER_FIRST + rest + ORDER_LAST, pads_by_net
def seg_cells(x1, y1, x2, y2, L):
n = max(1, int(math.hypot(x2 - x1, y2 - y1) / (R.G / 2)))
out = set()
for i in range(n + 1):
t = i / n
ix, iy = int(round((x1 + t * (x2 - x1)) / R.G)), int(round((y1 + t * (y2 - y1)) / R.G))
out.add((L, ix, iy))
return out
def route_all(parts, order, pads_by_net, verbose=True):
widths = set(WIDTH.values()) | set(FALLBACK) | {0.4}
radii = {radius(w) for w in widths} | {via_radius(w) for w in widths}
rt = R.Router(D.BOARD_W, D.BOARD_H, radii)
rt.block_edges()
rt.block_rect(18.75, 56.9, 67.25, D.BOARD_H) # ESP32 antenna keep-out
for p in parts.values():
for pad in p.pads():
rt.add_pad(pad)
rt.keep_vias_off_pad(pad, VIA_PAD_GAP)
pre_components = {} # net -> list of cell sets (each one electrically connected)
for net, pr in PREROUTE.items():
own = rt.net_cells.setdefault(net, set())
items = [] # (cells, endpoints)
for (x, y) in pr.get('vias', []):
rt.add_via(x, y, net)
ix, iy = rt.cell(x, y)
items.append(({(0, ix, iy), (1, ix, iy)}, {(round(x, 3), round(y, 3))}))
for (x1, y1, x2, y2, w, L) in pr['tracks']:
rt.add_track(x1, y1, x2, y2, w, L, net)
items.append((seg_cells(x1, y1, x2, y2, L), {(round(x1, 3), round(y1, 3)), (round(x2, 3), round(y2, 3))}))
# union items sharing an endpoint
comps = []
for cells, ends in items:
merged = [c for c in comps if c[1] & ends]
for c in merged:
comps.remove(c)
cells = cells | c[0]
ends = ends | c[1]
comps.append((cells, ends))
ports = pr.get('ports')
pc = []
for c in comps:
own |= c[0]
if ports:
pcells = set()
for (px, py, Ls) in ports:
ix, iy = rt.cell(px, py)
if any((L, ix, iy) in c[0] for L in (0, 1)) or any(abs(px - e[0]) < 1e-6 and abs(py - e[1]) < 1e-6 for e in c[1]):
for L in Ls:
pcells.add((L, ix, iy))
pc.append((c[0], pcells if pcells else None))
else:
pc.append((c[0], None))
pre_components[net] = pc
failures = []
stats = {}
t0 = time.time()
for net in order:
pads = pads_by_net[net]
w0 = WIDTH[net]
# terminals: pre-routed components (with the pads they touch) and the remaining pads
terms = []
used = set()
for comp, port_cells in pre_components.get(net, []):
cells = set(comp)
pts = []
for p in pads:
pc = rt.pad_cells(p)
if pc & comp:
cells |= pc
used.add(id(p))
pts.append((p.x, p.y))
if port_cells:
cells = set(port_cells)
c = next(iter(port_cells))
pts = [(c[1] * R.G, c[2] * R.G)]
if not pts:
c = next(iter(comp))
pts = [(c[1] * R.G, c[2] * R.G)]
terms.append((cells, pts[0][0], pts[0][1], f'pre:{net}'))
for p in pads:
if id(p) not in used:
terms.append((rt.pad_cells(p), p.x, p.y, f'{p.ref}.{p.num}'))
first = min(terms, key=lambda t: (t[1] + t[2]))
tree = set(first[0])
remaining = [t for t in terms if t is not first]
nseg = nvia = 0
while remaining:
sample = list(tree)[::max(1, len(tree) // 400)]
def dist_to_tree(t):
return min(abs(t[1] - c[1] * R.G) + abs(t[2] - c[2] * R.G) for c in sample)
t = min(remaining, key=dist_to_tree)
targets = t[0]
remaining.remove(t)
if targets & tree: # already connected (pads touching / overlapping)
tree |= targets
continue
path = None
tries = [w0] if net in NO_FALLBACK else [w0] + [fw for fw in FALLBACK if fw < w0]
for w in tries:
path = rt.search(net, radius(w), via_radius(w), tree, targets, t[1], t[2])
if path:
break
if not path:
failures.append((net, t[3]))
continue
a, b = rt.commit_path(net, path, w)
nseg += a; nvia += b
tree |= set(path) | targets
rt.net_cells[net] |= targets
stats[net] = (nseg, nvia)
if verbose:
print(f' {net:10s} pads={len(pads):2d} segs={nseg:3d} vias={nvia:2d} t={time.time() - t0:6.1f}s', flush=True)
return rt, failures
def stitch_vias(rt, pitch=4.0, dense=((33.0, 37.0, 53.5, 56.5, 2.0),)):
"""GND stitching vias: one per pitch x pitch block wherever a via fits and the ground pour
reaches it (connected) on both layers. Denser blocks under the ESP32 module."""
import pour
r_map = radius(0.2)
k = rt.nid('GND')
nx = rt.nx
keep = {}
for L in (0, 1):
mask = pour.fill_mask(rt, 'GND', L, r_map, open_cells=2)
anchors = [iy * nx + ix for (cl, ix, iy) in rt.net_cells.get('GND', ()) if cl == L]
anchors += [rt.cell(x, y)[1] * nx + rt.cell(x, y)[0] for (x, y, n) in rt.vias if n == 'GND']
keep[L] = pour.keep_connected(mask, nx, rt.ny, anchors)
mv = rt.maps[via_radius(0.2)]
gnd_vias = [(x, y) for (x, y, n) in rt.vias if n == 'GND']
def ok(ix, iy, min_gap):
if ix < 1 or iy < 1 or ix >= nx - 1 or iy >= rt.ny - 1:
return False
i = iy * nx + ix
if rt.no_via[i] or not (keep[0][i] and keep[1][i]):
return False
if any(mv[L][i] not in (-1, k) for L in (0, 1)):
return False
x, y = rt.xy(ix, iy)
return all(math.hypot(x - vx, y - vy) >= min_gap for vx, vy in gnd_vias)
def blocks(x0, y0, x1, y1, p):
s = int(round(p / R.G))
for by in range(int(y0 / R.G), int(y1 / R.G), s):
for bx in range(int(x0 / R.G), int(x1 / R.G), s):
yield bx, by, s
added = 0
passes = [(0.0, 0.0, D.BOARD_W, D.BOARD_H, pitch)] + list(dense)
for (x0, y0, x1, y1, p) in passes:
for bx, by, s in blocks(x0, y0, x1, y1, p):
cx, cy = bx + s // 2, by + s // 2
cands = sorted(((ix, iy) for iy in range(by, by + s) for ix in range(bx, bx + s)),
key=lambda c: (c[0] - cx) ** 2 + (c[1] - cy) ** 2)
for ix, iy in cands:
if ok(ix, iy, p * 0.6):
x, y = rt.xy(ix, iy)
rt.add_via(round(x, 3), round(y, 3), 'GND')
gnd_vias.append((x, y))
added += 1
break
print(f' stitching vias: {added}')
return added
def gnd_pours(rt, parts):
"""GND copper pour on both layers, filled by pour.py (clearance 0.3 mm)."""
import pour
from board import uid, fmt, net_list
from sexpr import QStr
OX, OY = D.ORIGIN
k = rt.nid('GND')
r_map = radius(0.2) # 0.38 mm: 0.3 pour clearance + half cell diagonal
net_ids = net_list()
zones = []
for L, lname in ((1, 'B.Cu'), (0, 'F.Cu')):
mask = pour.fill_mask(rt, 'GND', L, r_map, open_cells=2)
# anchors: GND copper cells on this layer (pads, tracks, vias)
anchors = []
for (cl, ix, iy) in rt.net_cells.get('GND', ()):
if cl == L:
anchors.append(iy * rt.nx + ix)
for (x, y, n) in rt.vias:
if n == 'GND':
ix, iy = rt.cell(x, y)
anchors.append(iy * rt.nx + ix)
keep = pour.keep_connected(mask, rt.nx, rt.ny, anchors)
polys = pour.polygons(keep, rt.nx, rt.ny)
filled = []
for poly in polys:
pts = pour.to_mm(poly, R.G)
filled.append(['filled_polygon', ['layer', QStr(lname)],
['pts'] + [['xy', fmt(x + OX), fmt(y + OY)] for x, y in pts]])
cells = sum(keep)
print(f' GND pour {lname}: {len(polys)} islands, {cells * R.G * R.G:.0f} mm2')
inset = 0.25
outline = [(inset, inset), (D.BOARD_W - inset, inset), (D.BOARD_W - inset, D.BOARD_H - inset), (inset, D.BOARD_H - inset)]
zones.append(['zone', ['net', str(net_ids['GND'])], ['net_name', QStr('/GND')], ['layer', QStr(lname)],
['uuid', QStr(uid('gndzone', lname))], ['name', QStr('GND_' + lname[0])],
['hatch', 'edge', '0.5'], ['connect_pads', 'yes', ['clearance', '0.3']],
['min_thickness', '0.25'], ['filled_areas_thickness', 'no'],
['fill', 'yes', ['thermal_gap', '0.5'], ['thermal_bridge_width', '0.5']],
['polygon', ['pts'] + [['xy', fmt(x + OX), fmt(y + OY)] for x, y in outline]]] + filled)
return zones
def silk_labels():
T = []
T.append(text_node('SANDTABLE', 53.8, 27.6, 'F.SilkS', size=1.1, bold=True))
T.append(text_node(f'CTRL v{D.VERSION}', 53.8, 29.6, 'F.SilkS', size=1.0))
for txt, x, y, ang in (('SPIN', 4.5, 15.2, 0), ('12V', 2.4, 17.6, 0), ('USB', 2.2, 37.3, 0),
('ARM', 71.0, 7.15, 90), ('HOME', 71.0, 21.0, 90), ('HALL', 71.4, 33.6, 90),
('LED', 71.0, 43.2, 90), ('EXP', 74.6, 55.8, 90)):
T.append(text_node(txt, x, y, 'F.SilkS', size=0.9, angle=ang))
# driver orientation: match the pin names printed on the DRV8825 module
for x1 in (38.1, 59.1): # A1 / A2 pin-1 x; pins step 2.54 mm to the left
T.append(text_node('VMOT', x1 - 7 * 2.54, 3.5, 'F.SilkS', size=0.8))
T.append(text_node('EN', x1 - 7 * 2.54, 13.0, 'F.SilkS', size=0.8))
T.append(text_node('DIR', x1, 13.0, 'F.SilkS', size=0.8))
# back side: name plate
T.append(text_node(f'SANDTABLE CONTROLLER v{D.VERSION}', 40.0, 30.0, 'B.SilkS', size=1.6, bold=True))
T.append(text_node('ESP32 + 2x DRV8825 | 12V in | FluidNC', 40.0, 33.0, 'B.SilkS', size=1.0))
T.append(text_node('lazypugs 2026', 40.0, 35.5, 'B.SilkS', size=1.0))
# JP1 (under the theta driver): bridge only for A4988 modules
jx, jy = D.PARTS['JP1'][3], D.PARTS['JP1'][4]
T.append(text_node('A4988 ONLY', jx, jy + 1.9, 'F.SilkS', size=0.7))
T.append(text_node('WayWayWay', 40.0, 38.0, 'B.SilkS', size=1.0)) # PCBWay prints its order number here
return T
def main():
parts = build()
probs = check_placement(parts)
for p in probs:
print(' !', p)
order, pads_by_net = net_order(parts)
rt, failures = route_all(parts, order, pads_by_net)
print(f'tracks={len(rt.tracks)} vias={len(rt.vias)} failures={len(failures)}')
crowded = [(x, y, pd.ref, pd.num) for (x, y, n) in rt.vias for p in parts.values() for pd in p.pads()
if pd.layers and pd.kind != 'np_thru_hole' and pd.dist(x, y) < R.VIA_D / 2 + VIA_PAD_GAP - 1e-6]
print(f'vias closer than {VIA_PAD_GAP} mm to a pad: {len(crowded)}', crowded[:6])
for f in failures:
print(' FAILED', f)
stitch_vias(rt)
tracks = [(x1, y1, x2, y2, w, L, n) for (x1, y1, x2, y2, w, L, n) in rt.tracks]
zones = [keepout_zone('ANTENNA', 18.75, 56.9, 67.25, D.BOARD_H)]
zones += gnd_pours(rt, parts)
os.makedirs(OUT, exist_ok=True)
write_pcb(os.path.join(OUT, 'sandtable-controller.kicad_pcb'), parts, tracks=tracks, vias=rt.vias,
zones=zones, texts=silk_labels())
write_project(os.path.join(OUT, 'sandtable-controller.kicad_pro'), 'sandtable-controller')
return failures
if __name__ == '__main__':
main()
+289
View File
@@ -0,0 +1,289 @@
"""A small two-layer grid router (A* with turn penalties, vias, per-net widths).
Obstacle model: for every distinct "inflation radius" R (track half-width +
clearance + discretisation margin) we keep, per layer, an owner map:
-1 = free, k >= 0 = only copper of net k is within R, -2 = blocked for everyone.
A cell is usable by net k iff its owner is -1 or k.
"""
import heapq
import math
from array import array
G = 0.1 # grid pitch, mm
CLEAR = 0.2 # copper-copper clearance used by the router (DRC default is 0.15)
EDGE = 0.35 # copper-to-board-edge clearance
MARGIN = 0.08 # discretisation safety margin
VIA_D = 0.6
COST_STEP = 10
COST_DIAG = 14
COST_VIA = 90
COST_TURN = 4
COST_BCU = 1 # tiny extra per step on the bottom layer (keeps B.Cu freer for GND)
DIRS = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
def seg_dist(px, py, x1, y1, x2, y2):
dx, dy = x2 - x1, y2 - y1
L2 = dx * dx + dy * dy
if L2 == 0:
return math.hypot(px - x1, py - y1)
t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / L2))
return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
class Router:
def __init__(self, width, height, radii):
self.W, self.H = width, height
self.nx = int(round(width / G)) + 1
self.ny = int(round(height / G)) + 1
self.radii = sorted(set(round(r, 3) for r in radii))
n = self.nx * self.ny
self.maps = {r: [array('h', [-1]) * n, array('h', [-1]) * n] for r in self.radii}
self.net_index = {}
self.objects = [] # (kind, data, net) for re-rasterising
self.tracks = [] # (x1,y1,x2,y2,w,layer,net)
self.vias = [] # (x,y,net)
self.net_cells = {} # net -> set of (layer, ix, iy) owned copper cells (tree)
self.no_via = bytearray(n) # 1 = a via centred here would touch or crowd a pad (any net)
# ------------------------------------------------------------ helpers
def nid(self, net):
if net is None:
return -2
if net not in self.net_index:
self.net_index[net] = len(self.net_index)
return self.net_index[net]
def cell(self, x, y):
return int(round(x / G)), int(round(y / G))
def xy(self, ix, iy):
return ix * G, iy * G
def _mark(self, layer_ids, net, x0, y0, x1, y1, inside):
"""inside(px,py,R) -> True if cell centre is within R of the object."""
k = self.nid(net)
for r in self.radii:
ix0 = max(0, int((x0 - r) / G) - 1)
iy0 = max(0, int((y0 - r) / G) - 1)
ix1 = min(self.nx - 1, int((x1 + r) / G) + 1)
iy1 = min(self.ny - 1, int((y1 + r) / G) + 1)
for L in layer_ids:
m = self.maps[r][L]
for iy in range(iy0, iy1 + 1):
py = iy * G
base = iy * self.nx
for ix in range(ix0, ix1 + 1):
idx = base + ix
v = m[idx]
if v == -2 or v == k:
continue
if inside(ix * G, py, r):
m[idx] = k if v == -1 else -2
def add_pad(self, pad):
layers = [0 if l == 'F.Cu' else 1 for l in pad.layers]
if pad.kind == 'np_thru_hole':
layers = [0, 1]
hole = pad.drill[0] / 2 if pad.drill else pad.hx
cx, cy = pad.x, pad.y
self._mark(layers, None, cx - hole, cy - hole, cx + hole, cy + hole,
lambda px, py, r: math.hypot(px - cx, py - cy) < hole + r)
return
net = pad.net
self._mark(layers, net, pad.x - pad.hx, pad.y - pad.hy, pad.x + pad.hx, pad.y + pad.hy,
lambda px, py, r: pad.dist(px, py) < r)
# drilled THT pads also block the other layer for different nets via the copper ring (both layers)
def keep_vias_off_pad(self, pad, gap):
"""Forbid via centres within (via radius + gap) of this pad's copper, whatever its net.
A via in or touching a pad lets solder drain into the hole during assembly."""
if pad.kind == 'np_thru_hole' or not pad.layers:
return
r = VIA_D / 2 + gap
ix0, iy0 = max(0, int((pad.x - pad.hx - r) / G) - 1), max(0, int((pad.y - pad.hy - r) / G) - 1)
ix1, iy1 = min(self.nx - 1, int((pad.x + pad.hx + r) / G) + 1), min(self.ny - 1, int((pad.y + pad.hy + r) / G) + 1)
for iy in range(iy0, iy1 + 1):
for ix in range(ix0, ix1 + 1):
if pad.dist(ix * G, iy * G) < r:
self.no_via[iy * self.nx + ix] = 1
def add_track(self, x1, y1, x2, y2, w, layer, net):
hw = w / 2
self._mark([layer], net, min(x1, x2) - hw, min(y1, y2) - hw, max(x1, x2) + hw, max(y1, y2) + hw,
lambda px, py, r: seg_dist(px, py, x1, y1, x2, y2) < hw + r)
self.tracks.append((x1, y1, x2, y2, w, 'F.Cu' if layer == 0 else 'B.Cu', net))
def add_via(self, x, y, net):
rv = VIA_D / 2
self._mark([0, 1], net, x - rv, y - rv, x + rv, y + rv,
lambda px, py, r: math.hypot(px - x, py - y) < rv + r)
self.vias.append((x, y, net))
def block_edges(self):
for r in self.radii:
lim = EDGE + r - CLEAR # r already includes clearance; edge rule replaces it
n = int(math.ceil(lim / G)) # cells with index < n (or > max-n) are too close
for L in (0, 1):
m = self.maps[r][L]
for iy in range(self.ny):
base = iy * self.nx
if iy < n or iy > self.ny - 1 - n:
for ix in range(self.nx):
m[base + ix] = -2
else:
for ix in list(range(n)) + list(range(self.nx - n, self.nx)):
m[base + ix] = -2
def block_rect(self, x0, y0, x1, y1):
"""Keep-out: nothing may enter (tracks are inflated by their radius)."""
for r in self.radii:
for L in (0, 1):
m = self.maps[r][L]
hw = r - CLEAR - MARGIN # track half width
for iy in range(max(0, int((y0 - hw) / G)), min(self.ny, int((y1 + hw) / G) + 2)):
for ix in range(max(0, int((x0 - hw) / G)), min(self.nx, int((x1 + hw) / G) + 2)):
px, py = ix * G, iy * G
if x0 - hw - CLEAR <= px <= x1 + hw + CLEAR and y0 - hw - CLEAR <= py <= y1 + hw + CLEAR:
m[iy * self.nx + ix] = -2
# ------------------------------------------------------------ search
def pad_cells(self, pad):
layers = [0 if l == 'F.Cu' else 1 for l in pad.layers]
cells = set()
ix0, iy0 = int((pad.x - pad.hx) / G), int((pad.y - pad.hy) / G)
ix1, iy1 = int((pad.x + pad.hx) / G) + 1, int((pad.y + pad.hy) / G) + 1
for iy in range(iy0, iy1 + 1):
for ix in range(ix0, ix1 + 1):
if pad.dist(ix * G, iy * G) <= 1e-9:
for L in layers:
cells.add((L, ix, iy))
if not cells:
ix, iy = self.cell(pad.x, pad.y)
for L in layers:
cells.add((L, ix, iy))
return cells
def search(self, net, r_track, r_via, sources, targets, tx, ty, max_expand=1500000):
k = self.nid(net)
nx = self.nx
mt = self.maps[r_track]
mv = self.maps[r_via]
own = self.net_cells.setdefault(net, set())
tset = targets
txi, tyi = tx / G, ty / G
def free(L, ix, iy):
if ix < 0 or iy < 0 or ix >= nx or iy >= self.ny:
return False
if (L, ix, iy) in tset:
return True
v = mt[L][iy * nx + ix]
return v == -1 or v == k
def via_ok(ix, iy):
if self.no_via[iy * nx + ix]:
return False
for L in (0, 1):
v = mv[L][iy * nx + ix]
if not (v == -1 or v == k):
return False
return True
def h(ix, iy):
dx, dy = abs(ix - txi), abs(iy - tyi)
return int(COST_STEP * (max(dx, dy) - min(dx, dy)) + COST_DIAG * min(dx, dy))
openh = []
g = {}
came = {}
for (L, ix, iy) in sources:
s = (L, ix, iy, 8)
g[s] = 0
heapq.heappush(openh, (h(ix, iy), 0, s))
expanded = 0
while openh:
f, gc, s = heapq.heappop(openh)
if g.get(s, 1 << 60) < gc:
continue
L, ix, iy, d = s
if (L, ix, iy) in tset:
path = [(L, ix, iy)]
while s in came:
s = came[s]
path.append(s[:3])
path.reverse()
return path
expanded += 1
if expanded > max_expand:
return None
for nd, (dx, dy) in enumerate(DIRS):
jx, jy = ix + dx, iy + dy
if not free(L, jx, jy):
continue
if dx and dy:
# don't cut corners between two blocked orthogonal neighbours
if not (free(L, ix + dx, iy) or free(L, ix, iy + dy)):
continue
step = COST_DIAG
else:
step = COST_STEP
if d != 8 and nd != d:
step += COST_TURN * (1 if (nd - d) % 8 in (1, 7) else 3)
if L == 1:
step += COST_BCU
ns = (L, jx, jy, nd)
ng = gc + step
if ng < g.get(ns, 1 << 60):
g[ns] = ng
came[ns] = s
heapq.heappush(openh, (ng + h(jx, jy), ng, ns))
# via
if via_ok(ix, iy):
ns = (1 - L, ix, iy, 8)
ng = gc + COST_VIA
if ng < g.get(ns, 1 << 60) and free(1 - L, ix, iy):
g[ns] = ng
came[ns] = s
heapq.heappush(openh, (ng + h(ix, iy), ng, ns))
return None
def commit_path(self, net, path, w, start_xy=None, end_xy=None):
"""Turn a cell path into merged segments + vias, add them as obstacles."""
own = self.net_cells.setdefault(net, set())
pts = [] # (layer, x, y)
for (L, ix, iy) in path:
pts.append((L, ix * G, iy * G))
own.add((L, ix, iy))
segs = []
vias = []
run = [pts[0]]
for p in pts[1:]:
if p[0] != run[-1][0]:
vias.append((p[1], p[2]))
if len(run) > 1:
segs.append(run)
run = [p]
else:
run.append(p)
if len(run) > 1:
segs.append(run)
out = []
for run in segs:
# merge collinear points
simp = [run[0]]
for i in range(1, len(run) - 1):
a, b, c = simp[-1], run[i], run[i + 1]
d1 = (round((b[1] - a[1]) / G), round((b[2] - a[2]) / G))
d2 = (round((c[1] - b[1]) / G), round((c[2] - b[2]) / G))
if d1[0] * d2[1] - d1[1] * d2[0] != 0 or d1[0] * d2[0] + d1[1] * d2[1] < 0:
simp.append(b)
simp.append(run[-1])
for a, b in zip(simp, simp[1:]):
out.append((a[1], a[2], b[1], b[2], w, a[0], net))
for (x, y) in vias:
self.add_via(x, y, net)
for (x1, y1, x2, y2, ww, L, n) in out:
self.add_track(x1, y1, x2, y2, ww, L, n)
return len(out), len(vias)
+271
View File
@@ -0,0 +1,271 @@
"""Generate the KiCad schematic from design.py (label-style: every pin gets a stub + net label)."""
import copy
import os
import sys
sys.path.insert(0, os.path.dirname(__file__))
import design as D
from board import uid, fmt
from kilib import load_symbol, sym_pins
from sexpr import QStr, dumps, find, findall
OUT = os.path.expanduser('~/kicad/sandtable-controller')
NAME = 'sandtable-controller'
ROOT = uid('rootsheet')
STUB = 2.54
LIBNICK = 'SandTable'
# custom symbols kept in the project library
CUSTOM = {
'Driver_Motor:Pololu_Breakout_A4988': ('DRV8825_StepStick', {
'2': ('VDD/~{FAULT}', 'passive'), '3': ('A2', None), '4': ('A1', None), '5': ('B1', None), '6': ('B2', None),
'9': ('~{EN}', None), '10': ('M0', None), '11': ('M1', None), '12': ('M2', None),
'13': ('~{RST}', None), '14': ('~{SLP}', None)}),
'Interface_USB:CH340C': ('CH340C_3V3', {'4': ('V3', 'passive')}),
}
GROUPS = [
('POWER INPUT 12V -> 5V', (12.7, 25.4, 287.02, 104.14), ['J1', 'F1', 'D1', 'C3', 'PS1', 'C4', 'D2', '#FLG1', '#FLG2', '#FLG3']),
('3.3V', (294.64, 25.4, 398.78, 104.14), ['U3', 'C5', 'C6']),
('SENSORS / LED / EXPANSION', (406.4, 25.4, 579.12, 238.76), ['J5', 'R9', 'R10', 'C13', 'D6', 'J6', 'R11', 'R12', 'C14', 'D7', 'U4', 'C15', 'R13', 'J7', 'J8']),
('USB-C + USB-SERIAL + AUTO-RESET', (12.7, 116.84, 190.5, 264.16), ['J2', 'U5', 'R1', 'R2', 'D3', 'U2', 'C7', 'Q1', 'R3', 'Q2', 'R4', '#FLG4']),
('ESP32', (198.12, 116.84, 398.78, 264.16), ['U1', 'C9', 'C10', 'R5', 'C8', 'SW1', 'R6', 'SW2', 'R8', 'R14', 'D4', 'R15', 'D5']),
('STEPPER DRIVERS (DRV8825, 1/32 step: M0-M2, RST, SLP tied high; JP1 bridged only for A4988)', (12.7, 276.86, 398.78, 406.4), ['A1', 'C1', 'C16', 'J3', 'A2', 'C2', 'C17', 'J4', 'JP1']),
('MOUNTING (Arduino Uno pattern)', (406.4, 251.46, 579.12, 299.72), ['H1', 'H2', 'H3', 'H4']),
]
FLAGS = {'#FLG1': 'GND', '#FLG2': '+12V', '#FLG3': '+5V', '#FLG4': 'VBUS'}
def snap(v, g=2.54):
return round(v / g) * g
def symbol_def(lib_id):
"""(lib_id used in the schematic, symbol definition list)."""
sym = copy.deepcopy(load_symbol(lib_id))
lib_name = str(sym[1])
if lib_id in CUSTOM:
new, renames = CUSTOM[lib_id]
for unit in findall(sym, 'symbol'):
unit[1] = QStr(str(unit[1]).replace(lib_name, new, 1))
for p in findall(unit, 'pin'):
num = str(find(p, 'number')[1])
if num in renames:
nm, typ = renames[num]
find(p, 'name')[1] = QStr(nm)
if typ:
p[1] = typ
full = f'{LIBNICK}:{new}'
sym[1] = QStr(full)
return full, sym, new
sym[1] = QStr(lib_id)
return lib_id, sym, None
def bbox(sym):
xs, ys = [], []
for unit in findall(sym, 'symbol'):
for g in unit:
if not isinstance(g, list):
continue
if g[0] == 'rectangle':
s, e = find(g, 'start'), find(g, 'end')
xs += [float(s[1]), float(e[1])]; ys += [float(s[2]), float(e[2])]
elif g[0] in ('polyline',):
for xy in findall(find(g, 'pts'), 'xy'):
xs.append(float(xy[1])); ys.append(float(xy[2]))
elif g[0] == 'circle':
c = find(g, 'center'); r = float(find(g, 'radius')[1])
xs += [float(c[1]) - r, float(c[1]) + r]; ys += [float(c[2]) - r, float(c[2]) + r]
for p in sym_pins(sym):
xs.append(p['x']); ys.append(p['y'])
if not xs:
return (-2.54, -2.54, 2.54, 2.54)
return (min(xs), min(ys), max(xs), max(ys))
def pin_net_map():
m = {}
for net, pins in D.NETS.items():
for p in pins:
m[p] = net
return m
def outward(angle):
"""Unit vector (schematic coords, y down) pointing away from the symbol body."""
a = int(round(angle)) % 360
return {0: (-1, 0), 180: (1, 0), 90: (0, 1), 270: (0, -1)}[a]
def label_angle(o):
return {(1, 0): 0, (-1, 0): 180, (0, -1): 90, (0, 1): 270}[o]
def font(size=1.27, hide=False, justify=None, bold=False):
f = ['font', ['size', fmt(size), fmt(size)]] + ([['bold', 'yes']] if bold else [])
e = ['effects', f]
if justify:
e.append(['justify'] + justify)
if hide:
e.append(['hide', 'yes'])
return e
def main():
pin_net = pin_net_map()
nc = set(D.NO_CONNECT)
items = [] # schematic body items
lib_syms = {}
custom_defs = {}
instances = {} # ref -> (lib_id, sym, value, footprint, lcsc, descr)
for ref, (sym_id, fp_id, value, *_rest) in D.PARTS.items():
lcsc, descr = _rest[-2], _rest[-1]
full, sym, custom = symbol_def(sym_id)
lib_syms[full] = sym
if custom:
custom_defs[custom] = sym
instances[ref] = (full, sym, value, fp_id, lcsc, descr)
flag_full, flag_sym, _ = symbol_def('power:PWR_FLAG')
lib_syms[flag_full] = flag_sym
for f in FLAGS:
instances[f] = (flag_full, flag_sym, 'PWR_FLAG', '', '', '')
# sanity: every pin of every part is either in a net or no-connect
missing = []
for ref, (full, sym, *_r) in instances.items():
if ref.startswith('#'):
continue
for p in sym_pins(sym):
key = f'{ref}.{p["num"]}'
if key not in pin_net and key not in nc:
missing.append(key)
if missing:
print('UNASSIGNED PINS:', missing)
texts = []
for title, (x0, y0, x1, y1), refs in GROUPS:
texts.append(['text', QStr(title), ['exclude_from_sim', 'no'], ['at', fmt(x0), fmt(y0 - 3.81), '0'],
font(2.0, bold=True, justify=['left', 'bottom']), ['uuid', QStr(uid('grp', title))]])
texts.append(['rectangle', ['start', fmt(x0 - 2.54), fmt(y0 - 2.54)], ['end', fmt(x1), fmt(y1)],
['stroke', ['width', '0.2'], ['type', 'dash']], ['fill', ['type', 'none']],
['uuid', QStr(uid('grprect', title))]])
# pack symbols left->right, wrap
cx, cy, row_h = x0 + 2.54, y0 + 2.54, 0
for ref in refs:
full, sym, value, fp_id, lcsc, descr = instances[ref]
bx0, by0, bx1, by1 = bbox(sym)
pins = sym_pins(sym)
lab = max([len(pin_net.get(f'{ref}.{p["num"]}', '') or ('X' if ref.startswith('#') else '')) for p in pins] + [0])
lab_w = STUB + 1.0 * 1.27 * lab + 1.27
left = any(outward(p['angle'])[0] < 0 for p in pins)
right = any(outward(p['angle'])[0] > 0 for p in pins)
up = any(outward(p['angle'])[1] < 0 for p in pins)
dn = any(outward(p['angle'])[1] > 0 for p in pins)
w = (bx1 - bx0) + (lab_w if left else 2.54) + (lab_w if right else 2.54) + 5.08
h = (by1 - by0) + (lab_w if up else 5.08) + (lab_w if dn else 5.08) + 2.54
if cx + w > x1 and cx > x0 + 2.54:
cx = x0 + 2.54
cy += row_h
row_h = 0
# symbol origin so that its bbox sits inside the slot
ox = snap(cx + (lab_w if left else 2.54) - bx0, 1.27)
oy = snap(cy + (lab_w if up else 5.08) + by1, 1.27)
instances[ref] = (full, sym, value, fp_id, lcsc, descr, ox, oy, (by1, by0))
cx += w
row_h = max(row_h, h)
if cy + row_h > y1 + 0.1:
print(f' ! group "{title}" overflows its box by {cy + row_h - y1:.1f} mm')
sym_nodes = []
flag_count = 0
for ref, inst in instances.items():
full, sym, value, fp_id, lcsc, descr, ox, oy, (top, bot) = inst
is_flag = ref.startswith('#')
node = ['symbol', ['lib_id', QStr(full)], ['at', fmt(ox), fmt(oy), '0'], ['unit', '1'],
['exclude_from_sim', 'no'], ['in_bom', 'no' if (is_flag or ref.startswith(('H', 'JP'))) else 'yes'],
['on_board', 'no' if is_flag else 'yes'], ['dnp', 'no'],
['uuid', QStr(uid('sym', ref))]]
props = {str(p[1]): p for p in findall(sym, 'property')}
ref_y = oy - top - 1.27
val_y = oy - bot + 2.54
node.append(['property', QStr('Reference'), QStr(ref), ['at', fmt(ox), fmt(ref_y), '0'],
font(hide=is_flag)])
node.append(['property', QStr('Value'), QStr(value), ['at', fmt(ox), fmt(val_y), '0'], font(hide=is_flag)])
node.append(['property', QStr('Footprint'), QStr(fp_id), ['at', fmt(ox), fmt(oy), '0'], font(hide=True)])
ds = props.get('Datasheet')
node.append(['property', QStr('Datasheet'), QStr(str(ds[2]) if ds else ''), ['at', fmt(ox), fmt(oy), '0'], font(hide=True)])
node.append(['property', QStr('Description'), QStr(descr), ['at', fmt(ox), fmt(oy), '0'], font(hide=True)])
if lcsc:
node.append(['property', QStr('LCSC'), QStr(lcsc), ['at', fmt(ox), fmt(oy), '0'], font(hide=True)])
pins = sym_pins(sym)
seen = set()
for p in pins:
if p['num'] in seen:
continue
seen.add(p['num'])
node.append(['pin', QStr(p['num']), ['uuid', QStr(uid('pin', ref, p['num']))]])
node.append(['instances', ['project', QStr(NAME), ['path', QStr('/' + ROOT), ['reference', QStr(ref)], ['unit', '1']]]])
sym_nodes.append(node)
# wires + labels / no-connects
done = set()
for p in pins:
key = f'{ref}.{p["num"]}'
px, py = ox + p['x'], oy - p['y']
if (round(px, 3), round(py, 3)) in done:
continue
done.add((round(px, 3), round(py, 3)))
net = FLAGS[ref] if is_flag else pin_net.get(key)
if net is None:
if key in nc or p['type'] == 'no_connect':
items.append(['no_connect', ['at', fmt(px), fmt(py)], ['uuid', QStr(uid('nc', key, px, py))]])
continue
o = outward(p['angle'])
qx, qy = px + STUB * o[0], py + STUB * o[1]
items.append(['wire', ['pts', ['xy', fmt(px), fmt(py)], ['xy', fmt(qx), fmt(qy)]],
['stroke', ['width', '0'], ['type', 'default']], ['uuid', QStr(uid('w', key, px, py))]])
ang = label_angle(o)
just = ['left', 'bottom'] if ang in (0, 90) else ['right', 'bottom']
items.append(['label', QStr(net), ['at', fmt(qx), fmt(qy), str(ang)], font(justify=just),
['uuid', QStr(uid('lbl', key, px, py))]])
root = ['kicad_sch', ['version', '20250114'], ['generator', QStr('eeschema')], ['generator_version', QStr('9.0')],
['uuid', QStr(ROOT)], ['paper', QStr('A2')],
['title_block', ['title', QStr('SandTable Controller')], ['date', QStr('2026-10-22')], ['rev', QStr(D.VERSION)],
['company', QStr('lazypugs')],
['comment', '1', QStr('ESP32-WROOM-32E (FluidNC) + 2x DRV8825 StepStick, 12V in')],
['comment', '2', QStr('Drop-in for the Arduino+CNC shield bay in the sand table (Uno hole pattern)')]],
['lib_symbols'] + list(lib_syms.values())]
root += texts + items + sym_nodes
root.append(['sheet_instances', ['path', QStr('/'), ['page', QStr('1')]]])
root.append(['embedded_fonts', 'no'])
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, NAME + '.kicad_sch'), 'w') as f:
f.write(dumps(root) + '\n')
# project symbol library with the custom symbols
lib = ['kicad_symbol_lib', ['version', '20241209'], ['generator', QStr('sandtable-gen')], ['generator_version', QStr('9.0')]]
for name, s in custom_defs.items():
s2 = copy.deepcopy(s)
s2[1] = QStr(name)
lib.append(s2)
with open(os.path.join(OUT, 'sandtable.kicad_sym'), 'w') as f:
f.write(dumps(lib) + '\n')
symlibs = sorted({sid.split(':')[0] for sid in [v[0] for v in D.PARTS.values()] + ['power:PWR_FLAG']})
with open(os.path.join(OUT, 'sym-lib-table'), 'w') as f:
f.write('(sym_lib_table\n (version 7)\n')
f.write(' (lib (name "SandTable")(type "KiCad")(uri "${KIPRJMOD}/sandtable.kicad_sym")(options "")(descr "SandTable custom symbols"))\n')
for lib in symlibs:
f.write(f' (lib (name "{lib}")(type "KiCad")(uri "${{KICAD9_SYMBOL_DIR}}/{lib}.kicad_sym")(options "")(descr ""))\n')
f.write(')\n')
fplibs = sorted({v[1].split(':')[0] for v in D.PARTS.values()})
with open(os.path.join(OUT, 'fp-lib-table'), 'w') as f:
f.write('(fp_lib_table\n (version 7)\n')
for lib in fplibs:
f.write(f' (lib (name "{lib}")(type "KiCad")(uri "${{KICAD9_FOOTPRINT_DIR}}/{lib}.pretty")(options "")(descr ""))\n')
f.write(')\n')
print('wrote schematic,', len(sym_nodes), 'symbols,', sum(1 for i in items if i[0] == 'label'), 'labels,',
sum(1 for i in items if i[0] == 'no_connect'), 'no-connects')
if __name__ == '__main__':
main()
+100
View File
@@ -0,0 +1,100 @@
"""Tiny S-expression reader/writer for KiCad files.
Lists are Python lists; atoms are str. Quoted strings are kept as QStr so they
round-trip with quotes. Numbers stay as plain str atoms (KiCad cares about text).
"""
class QStr(str):
"""A string that was quoted in the source (and must be written quoted)."""
def parse(text):
pos = 0
n = len(text)
stack = [[]]
while pos < n:
c = text[pos]
if c in ' \t\r\n':
pos += 1
elif c == '(':
stack.append([])
pos += 1
elif c == ')':
done = stack.pop()
stack[-1].append(done)
pos += 1
elif c == '"':
pos += 1
buf = []
while True:
ch = text[pos]
if ch == '\\':
buf.append(text[pos:pos + 2])
pos += 2
continue
if ch == '"':
pos += 1
break
buf.append(ch)
pos += 1
raw = ''.join(buf)
# keep escapes as-is (we re-emit them unchanged)
stack[-1].append(QStr(raw))
else:
start = pos
while pos < n and text[pos] not in ' \t\r\n()"':
pos += 1
stack[-1].append(text[start:pos])
assert len(stack) == 1
return stack[0][0] if len(stack[0]) == 1 else stack[0]
def dumps(node, indent=0):
"""Serialize back to KiCad-style text (one sub-list per line when nested)."""
if isinstance(node, QStr):
return '"' + str(node) + '"'
if isinstance(node, str):
return node
if isinstance(node, (int, float)):
return fmt(node)
# list
simple = all(not isinstance(x, list) for x in node)
if simple:
return '(' + ' '.join(dumps(x) for x in node) + ')'
pad = ' ' * (indent + 1)
parts = []
head = []
i = 0
while i < len(node) and not isinstance(node[i], list):
head.append(dumps(node[i]))
i += 1
out = '(' + ' '.join(head)
for x in node[i:]:
if isinstance(x, list):
out += '\n' + pad + dumps(x, indent + 1)
else:
out += ' ' + dumps(x)
out += '\n' + ' ' * indent + ')'
return out
def fmt(v):
if isinstance(v, str):
return v
s = f'{v:.4f}'.rstrip('0').rstrip('.')
if s in ('-0', ''):
s = '0'
return s
def find(node, key):
"""First child list whose head is key."""
for x in node:
if isinstance(x, list) and x and x[0] == key:
return x
return None
def findall(node, key):
return [x for x in node if isinstance(x, list) and x and x[0] == key]