imrishabh18/pcbgolf
A complete 4-layer PCBGolf controller PCB with STM32H7 MCU, USB/SD and CAN interfaces, power regulation, protection, connectors, switches, LEDs, clocking, and supporting passives.
- Version
- 1.6.1
- License
- unset
- Stars
- 0
footprints/generate.py
#!/usr/bin/env python3
"""Convert the challenge's own KiCad footprints, retaining exact copper geometry.
Usage: python footprints/generate.py /path/to/PCBGolf/pcbgolf.pretty
Requires the installed `tsci` CLI. Its graphics conversion is retained; pads are
regenerated from the KiCad source to retain radii, mask/paste margins, and angles.
"""
import concurrent.futures
import json
import math
from pathlib import Path
import re
import subprocess
import sys
import tempfile
DEST = Path(__file__).resolve().parent
SOURCE = Path(sys.argv[1]).resolve()
# Corrections retain their own native source footprint alongside this generator.
ADDITIONAL_SOURCE = DEST / 'kicad'
INSERTION_DIRECTIONS = {
'DCJACK_2MM_SMT': 'from_left',
'DX07S024XJ1R1100': 'from_bottom',
'USB_C_Receptacle_GCT_USB4105-xx-A_16P_TopMnt_Horizontal': 'from_bottom',
'0472192001': 'from_above',
'2X04': 'from_above',
'USB-C-FEMALE-VERT-GCT': 'from_above',
}
def parse_sexpr(text):
tokens = re.findall(r'"(?:\\.|[^"\\])*"|[()]|[^\s()]+', text)
stack, root = [], None
for token in tokens:
if token == '(':
node = []
if stack:
stack[-1].append(node)
else:
root = node
stack.append(node)
elif token == ')':
stack.pop()
else:
stack[-1].append(json.loads(token) if token.startswith('"') else token)
return root
def child(node, name, default=None):
return next((n for n in node if isinstance(n, list) and n[0] == name), default)
def value(node, name, default=None):
item = child(node, name)
return item[1] if item else default
def number(value):
return round(float(value), 9)
def attrs(props):
return ' '.join(f'{key}={{{json.dumps(val, separators=(",", ":"))}}}' for key, val in props.items())
def convert(path):
source = parse_sexpr(path.read_text())
name = path.stem
pads = [n for n in source if isinstance(n, list) and n[0] == 'pad']
pin_ids = list(dict.fromkeys(p[1] for p in pads if p[1] and p[2] != 'np_thru_hole'))
pin_map = {p: f'pin{int(p)}' for p in pin_ids if p.isdigit() and int(p) > 0}
last_pin = max([int(p[3:]) for p in pin_map.values()] or [0])
for pad_id in pin_ids:
if pad_id not in pin_map:
last_pin += 1
pin_map[pad_id] = f'pin{last_pin}'
with tempfile.TemporaryDirectory() as temp:
output = Path(temp) / 'converted.tsx'
subprocess.run(['tsci', 'convert', str(path), '--name', 'SourceFootprint', '-o', str(output)], check=True, capture_output=True, text=True)
converted = output.read_text()
graphics = converted.split('<footprint>', 1)[1].split('</footprint>', 1)[0]
graphics = re.sub(r'<(?:smtpad|platedhole|hole)\b[^>]*?/>', '', graphics, flags=re.S)
# Component reference/value labels are provided by the board, not REF**.
graphics = re.sub(r'<(?:silkscreentext|fabricationnotetext)\b[^>]*(?:REF\*\*|reference)[^>]*?/>', '', graphics, flags=re.S | re.I)
graphics_lines = [line.strip() for line in graphics.splitlines() if line.strip()]
# Assembly courtyards are reviewed against native CAD bodies and pad extents.
courtyard_path = DEST / 'courtyard-additions.json'
courtyards = json.loads(courtyard_path.read_text()) if courtyard_path.exists() else {}
if name in courtyards:
row = courtyards[name]
outline = row.get('outline', [[row['xmin'], row['ymin']], [row['xmax'], row['ymin']], [row['xmax'], row['ymax']], [row['xmin'], row['ymax']]])
graphics_lines = [line for line in graphics_lines if '<courtyard' not in line]
points = [{'x': round(x, 6), 'y': round(y, 6)} for x, y in outline]
if row.get('shape') == 'rect':
cx = round((row['xmin'] + row['xmax']) / 2, 6)
cy = round((row['ymin'] + row['ymax']) / 2, 6)
width = round(row['xmax'] - row['xmin'], 6)
height = round(row['ymax'] - row['ymin'], 6)
graphics_lines.append(f'<courtyardrect pcbX={{{cx}}} pcbY={{{cy}}} width={{{width}}} height={{{height}}} layer="top" />')
else:
graphics_lines.append('<courtyardoutline outline={' + json.dumps(points) + '} layer="top" />')
# A collection of two-point outlines does not describe a closed area in
# Circuit JSON. Join connected source courtyard edges into closed polygons.
edges = []
retained = []
for line in graphics_lines:
match = re.search(r'<courtyardoutline outline=\{(\[.*?\])\}', line)
points = json.loads(match[1]) if match else []
if len(points) == 2:
edges.append(points)
else:
retained.append(line)
while edges:
outline = edges.pop(0)
while outline[-1] != outline[0]:
for i, edge in enumerate(edges):
if edge[0] == outline[-1] or edge[1] == outline[-1]:
outline.append(edge[1] if edge[0] == outline[-1] else edge[0])
edges.pop(i)
break
else:
raise ValueError('Open source courtyard: ' + name)
retained.append('<courtyardoutline outline={' + json.dumps(outline[:-1]) + '} layer="top" />')
graphics_lines = retained
copper_lines, copper_metadata = [], []
for index, pad in enumerate(pads):
pad_id, kind, shape = pad[1:4]
at = child(pad, 'at')
size = child(pad, 'size')
x, y = number(at[1]), -number(at[2])
rotation = number(at[3]) % 360 if len(at) > 3 else 0
width, height = number(size[1]), number(size[2])
props = {'name': f'pad_{index + 1}', 'pcbX': x, 'pcbY': y}
if kind != 'np_thru_hole' and pad_id:
# The canonical pin hint creates a robust generic-chip interface;
# the original pad ID remains available for inspection/selectors.
props['portHints'] = [pin_map[pad_id], pad_id]
layers = child(pad, 'layers')[1:]
mask = value(pad, 'solder_mask_margin', value(source, 'solder_mask_margin', 0))
paste = value(pad, 'solder_paste_margin', value(source, 'solder_paste_margin', 0))
if mask is not None:
props['solderMaskMargin'] = number(mask)
if kind == 'smd' and paste is not None:
props['solderPasteMargin'] = number(paste)
if kind == 'smd':
props['layer'] = 'bottom' if 'B.Cu' in layers else 'top'
assert 'F.Cu' in layers or 'B.Cu' in layers, (name, pad_id, layers)
radius = min(width, height) * number(value(pad, 'roundrect_rratio', 0))
# A square roundrect with a half-width corner radius is exactly a
# circle. Use the equivalent circle primitive so clearance checks
# measure copper rather than the square enclosing that copper.
if shape == 'roundrect' and abs(width - height) < 1e-9 and abs(radius - width / 2) < 1e-9:
props.update(shape='circle', radius=width / 2)
elif rotation % 90 == 0:
if rotation % 180:
width, height = height, width
if shape in ['rect', 'roundrect']:
props.update(shape='rect', width=width, height=height)
if radius:
props['cornerRadius'] = number(radius)
elif shape == 'circle':
assert width == height
props.update(shape='circle', radius=width / 2)
elif shape == 'oval':
props.update(shape='pill', width=width, height=height, radius=min(width, height) / 2)
else:
raise ValueError((name, pad_id, shape))
else:
assert shape in ['rect', 'roundrect'], (name, pad_id, shape)
props.update(shape='rotated_rect', width=width, height=height, ccwRotation=rotation)
if radius:
props['cornerRadius'] = number(radius)
tag = 'smtpad'
elif kind == 'np_thru_hole':
drill = child(pad, 'drill')
if drill[1] == 'oval':
props.update(shape='pill', width=number(drill[2]), height=number(drill[3]), pcbRotation=rotation)
else:
props['diameter'] = number(drill[1])
tag = 'hole'
elif kind == 'thru_hole':
drill = child(pad, 'drill')
if drill[1] == 'oval':
if shape in ['rect', 'roundrect']:
props.update(shape='pill_hole_with_rect_pad', holeWidth=number(drill[2]), holeHeight=number(drill[3]), rectPadWidth=width, rectPadHeight=height, pcbRotation=rotation)
else:
props.update(shape='pill', holeWidth=number(drill[2]), holeHeight=number(drill[3]), outerWidth=width, outerHeight=height, pcbRotation=rotation)
elif shape == 'circle':
assert width == height, (name, pad_id, size)
props.update(shape='circle', outerDiameter=width, holeDiameter=number(drill[1]))
elif shape in ['rect', 'roundrect']:
props.update(shape='circular_hole_with_rect_pad', holeDiameter=number(drill[1]), rectPadWidth=width, rectPadHeight=height, pcbRotation=rotation)
if shape == 'roundrect':
props['rectBorderRadius'] = number(min(width, height) * number(value(pad, 'roundrect_rratio', 0)))
else:
raise ValueError((name, pad_id, shape))
tag = 'platedhole'
else:
raise ValueError((name, pad_id, kind))
copper_lines.append(f'<{tag} {attrs(props)} />')
copper_metadata.append({'sourcePadId': pad_id, 'sourceKind': kind, 'sourceShape': shape, 'element': tag, **props})
fn = 'Footprint_' + re.sub(r'[^A-Za-z0-9_]', '_', name)
filename = fn + '.tsx'
body = '\n'.join(' ' + line for line in copper_lines + graphics_lines)
insertion = f' insertionDirection="{INSERTION_DIRECTIONS[name]}"' if name in INSERTION_DIRECTIONS else ''
origin = f'footprints/kicad/{path.name} (see source-corrections.json)' if path.parent == ADDITIONAL_SOURCE else f'commaai/PCBGolf/pcbgolf.pretty/{path.name}'
(DEST / filename).write_text(f'// Source: {origin}\n// Generated by footprints/generate.py. Coordinates are millimetres, +Y up.\nexport const {fn} = () => (\n <footprint originalLayer="top"{insertion}>\n{body}\n </footprint>\n)\n')
return name, filename, fn, pin_map, copper_metadata
files = sorted(SOURCE.glob('*.kicad_mod')) + sorted(ADDITIONAL_SOURCE.glob('*.kicad_mod'))
assert len({p.stem for p in files}) == len(files), 'Duplicate footprint names across original and correction libraries'
assert files, f'No .kicad_mod files found in {SOURCE}'
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(convert, files))
imports = '\n'.join(f'import {{ {fn} }} from "./{filename[:-4]}"' for name, filename, fn, pins, pads in results)
mapping = ',\n'.join(f' {json.dumps(name)}: {fn}' for name, filename, fn, pins, pads in results)
pins = {name: pin_map for name, filename, fn, pin_map, pads in results}
(DEST / 'index.tsx').write_text(imports + '\n\n/** Exact challenge footprints, keyed by original KiCad library name. */\nexport const footprints = {\n' + mapping + '\n} as const\n\n/** Original KiCad pad ID -> tscircuit canonical pin hint. */\nexport const footprintPinMaps: Record<string, Record<string, string>> = ' + json.dumps(pins, indent=2) + '\n\nexport type FootprintName = keyof typeof footprints\n')
metadata = {name: {'pinMap': pin_map, 'pads': pads} for name, filename, fn, pin_map, pads in results}
(DEST / 'geometry.json').write_text(json.dumps(metadata, indent=2) + '\n')
print(f'Generated {len(results)} footprints, {sum(len(pads) for *_, pads in results)} pads/holes.')