Files
sandTable/pcb/tools/sch.py
T
ckochandClaude Opus 5.5 f9b4c08d60 Point local work folders at ~/Documents/projects/sandTable-local
The KiCad working copy and the Pi image work dir moved out of ~/kicad and
~/Documents into ~/Documents/projects/sandTable-local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:48:07 -05:00

272 lines
13 KiB
Python

"""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('~/Documents/projects/sandTable-local/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()