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