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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user