Files
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

220 lines
10 KiB
Python

"""Build the PCBWay order files and copy the KiCad project + outputs into the repo.
Run after route.py and sch.py. Writes to ~/Documents/projects/sandTable-local/sandtable-controller/fab (outside
the repo), 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('~/Documents/projects/sandTable-local/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()