"""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)