seveibar/pd-dual-stepper-controller
Bottom-side PCB silkscreen artwork consisting of embedded mirrored TS Circuit SVG logos, with via-clearance-aware lower-board placement.
- Version
- 0.1.0
- License
- unset
- Stars
- 0
scripts/export-assembly.py
"""Export fabrication files, omitting DNP parts from all assembly CSVs."""
import sys
import csv
import hashlib
import io
import json
from pathlib import Path
import subprocess
import zipfile
root = Path(__file__).resolve().parents[1]
folder = (root / (sys.argv[1] if len(sys.argv)>1 else 'dist/dual')).resolve()
subprocess.run(['bunx', '--no-install', 'tsci', 'export', str(folder / 'circuit.json'),
'--format', 'gerbers', '--output', str(folder / 'gerbers.zip')], cwd=root, check=True)
circuit = json.loads((folder / 'circuit.json').read_text())
sources = {e['source_component_id']: e for e in circuit if e['type'] == 'source_component'}
dnp = {sources[e['source_component_id']]['name'] for e in circuit
if e['type'] == 'pcb_component' and e.get('do_not_place')}
bare = {s['name'] for s in sources.values() if s['name'].startswith(('TP_', 'MH'))}
# Supplier placement orientation is independent of the local CAD-model offset.
# Verified against the live JLCPCB lead-to-pad preview on 2026-09-15:
# Q_BUZZER / C20917 uses a zero offset. Q_PD_ENABLE now uses the exact
# C85202 supplier footprint rotated 270 degrees; its former 180-degree generic
# footprint correction is no longer needed. The resulting placement remains 270.
rotation_corrections = {'Q_BUZZER': ('C20917', 0), 'Q_PD_ENABLE': ('C85202', 0)}
pcb_by_name = {sources[e['source_component_id']]['name']: e for e in circuit
if e['type'] == 'pcb_component' and e['source_component_id'] in sources}
def correct_jlcpcb_rotation(data):
rows = list(csv.DictReader(io.StringIO(data.decode().replace('\\r\\n', '\n'))))
for row in rows:
if row['Designator'] not in rotation_corrections:
continue
part, offset = rotation_corrections[row['Designator']]
pcb = pcb_by_name[row['Designator']]
source = sources[pcb['source_component_id']]
assert part in source['supplier_part_numbers']['jlcpcb']
assert pcb['layer'] == 'top'
# Fail rather than double-correct if a newer CLI starts correcting CPLs.
assert float(row['Rotation']) % 360 == pcb['rotation'] % 360
row['Rotation'] = str((float(row['Rotation']) + offset) % 360)
out = io.StringIO(newline='')
writer = csv.DictWriter(out, fieldnames=list(rows[0]), lineterminator='\n')
writer.writeheader()
writer.writerows(rows)
return out.getvalue().encode()
def filtered(data, excluded):
# This CLI version emits literal escape sequences in its CSV line endings.
text = data.decode().replace('\\r\\n', '\n')
rows = list(csv.DictReader(io.StringIO(text)))
kept = [r for r in rows if r['Designator'] not in excluded]
out = io.StringIO(newline='')
writer = csv.DictWriter(out, fieldnames=list(rows[0]), lineterminator='\n')
writer.writeheader()
writer.writerows(kept)
return out.getvalue().encode(), kept
archive = folder / 'gerbers.zip'
with zipfile.ZipFile(archive) as z:
entries = {n: z.read(n) for n in z.namelist()}
entries['pick_and_place.csv'] = correct_jlcpcb_rotation(entries['pick_and_place.csv'])
assembly_designators = {}
for name, target in [('bom.csv', 'assembly-bom.csv'), ('pick_and_place.csv', 'assembly-pick-and-place.csv')]:
data, rows = filtered(entries[name], dnp | bare)
if name == 'bom.csv':
# Custom connectors have an MPN but no distributor ID; preserve it in
# the assembly BOM instead of exporting an empty component row.
for row in rows:
source = sources[pcb_by_name[row['Designator']]['source_component_id']]
if source.get('manufacturer_part_number') and not row.get('Value', '').strip():
row['Value'] = source['manufacturer_part_number']
row['Comment'] = source['manufacturer_part_number']
if row['Designator'] in ('J1', 'J2'):
row['Footprint'] = 'JST_PH_S4B-PH-K_1x04_P2.00mm_Horizontal'
out = io.StringIO(newline='')
writer = csv.DictWriter(out, fieldnames=list(rows[0]), lineterminator='\n')
writer.writeheader()
writer.writerows(rows)
data = out.getvalue().encode()
(folder / 'bom.csv').write_bytes(data)
entries[name] = data
(folder / target).write_bytes(data)
assembly_designators[name] = {r['Designator'] for r in rows}
assert not (assembly_designators[name] & (dnp | bare))
if name == 'pick_and_place.csv':
placements = rows
assert assembly_designators['bom.csv'] == assembly_designators['pick_and_place.csv'], 'BOM/CPL designators differ'
with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED) as z:
for name, data in entries.items():
z.writestr(name, data)
audit = {
'circuitJsonSha256': hashlib.sha256((folder / 'circuit.json').read_bytes()).hexdigest(),
'gerbersSha256': hashlib.sha256(archive.read_bytes()).hexdigest(),
'excludedDoNotPlaceComponents': sorted(dnp),
'excludedBarePcbAssemblyFeatures': sorted(bare),
'assemblyTopCount': sum(r['Layer'] == 'top' for r in placements),
'assemblyBottomComponents': [r['Designator'] for r in placements if r['Layer'] == 'bottom'],
'jlcpcbRotationCorrectionsDegrees': {name: value[1] for name, value in rotation_corrections.items()},
}
(root / 'reports/fabrication-audit.json').write_text(json.dumps(audit, indent=2) + '\n')
print(json.dumps(audit, indent=2))