rushabhcodes/desk-fidget-counter
A desk knob assembly combining a circuit board with PETG-printed mechanical parts and vendor CAD-modeled hardware.
- Version
- 5.6.1
- License
- MIT
- Stars
- 0
scripts/export-fabrication.py
#!/usr/bin/env python3
"""SPDX-License-Identifier: MIT. Finalize a source-derived KiCad export with declared fab rules.
Requires KiCad 10 Python (pcbnew) and kicad-cli. No DRC exclusions are set.
"""
from pathlib import Path
import argparse, csv, json, subprocess, sys, tempfile
import pcbnew
ROOT=Path(__file__).resolve().parents[1]
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('input_board',type=Path)
parser.add_argument('--check-only',action='store_true',help='Validate in a temporary directory without writing manufacturing outputs')
args=parser.parse_args()
OUT=Path(tempfile.mkdtemp(prefix='desk-fab-check-')) if args.check_only else ROOT/'manufacturing'
REPORT=OUT if args.check_only else ROOT/'validation'
OUT.mkdir(exist_ok=True)
raw=args.input_board
boardpath=OUT/'desk-knob-reve3.kicad_pcb'
b=pcbnew.LoadBoard(str(raw))
def routed_copper(board):
return sorted((type(t).__name__,t.GetNetname(),t.GetLayer(),
tuple(t.GetWidth(layer) for layer in (pcbnew.F_Cu,pcbnew.B_Cu)) if isinstance(t,pcbnew.PCB_VIA) else (t.GetWidth(),),
t.GetStart().x,t.GetStart().y,t.GetEnd().x,t.GetEnd().y,
t.GetDrillValue() if isinstance(t,pcbnew.PCB_VIA) else 0)
for t in board.GetTracks())
native_routes=routed_copper(b)
# Keep boards alive while pcbnew's footprint/zone proxy objects are in scope.
loaded_boards=[b]
source=json.loads((ROOT/'dist/index/circuit.json').read_text())
expected={c['name'] for c in source if c['type']=='source_component'}
assert {f.GetReference() for f in b.GetFootprints() if f.GetReference()}==expected, 'Raw KiCad export is stale; export the current circuit JSON first'
source_names={c['source_component_id']:c['name'] for c in source if c['type']=='source_component'}
pcb_components={c['pcb_component_id']:c for c in source if c['type']=='pcb_component'}
source_ports={c['source_port_id']:c for c in source if c['type']=='source_port'}
pcb_ports={c['pcb_port_id']:c for c in source if c['type']=='pcb_port'}
expected_pads={}
for p in [c for c in source if c['type'] in ['pcb_smtpad','pcb_plated_hole']]:
port=pcb_ports[p['pcb_port_id']];sp=source_ports[port['source_port_id']]
ref=source_names[pcb_components[p['pcb_component_id']]['source_component_id']]
expected_pads[ref,str(sp['pin_number'])]=(port,p)
checked=set()
for fp in b.GetFootprints():
for p in fp.Pads():
if not p.GetNumber():continue
key=(fp.GetReference(),p.GetNumber());port,physical=expected_pads[key]
assert abs(pcbnew.ToMM(p.GetPosition().x)-(100+port['x']))<.0002, f'{key} copper X differs from source'
assert abs(pcbnew.ToMM(p.GetPosition().y)-(100-port['y']))<.0002, f'{key} copper Y differs from source'
if physical['type']=='pcb_plated_hole':
w=physical.get('hole_diameter',physical.get('hole_width'))
h=physical.get('hole_diameter',physical.get('hole_height'))
for actual,wanted in zip(sorted([pcbnew.ToMM(p.GetDrillSize().x),pcbnew.ToMM(p.GetDrillSize().y)]),sorted([w,h])):
assert abs(actual-wanted)<.0002, f'{key} finished drill differs from source'
checked.add(key)
assert checked==set(expected_pads), 'Raw KiCad export does not contain every source copper pad'
# Preserve the source's optional-component population flag in KiCad.
dnp={c['source_component_id'] for c in source if c['type']=='pcb_component' and c.get('do_not_place')}
dnp_refs={c['name'] for c in source if c['type']=='source_component' and c['source_component_id'] in dnp}
for fp in b.GetFootprints():
if fp.GetReference() in dnp_refs:fp.SetDNP(True)
# All coordinates and nets come from tscircuit; refill removes floating copper islands.
for priority,z in enumerate(b.Zones(),1):
if z.GetIsRuleArea():
assert all(c.get('allow_placements') for c in source if c['type']=='pcb_keepout'), 'Keepout placement flags need individual mapping'
# Match allowPlacements in the source; housings span the routed window
# and the connector surrounds its locating holes. Copper stays blocked.
z.SetDoNotAllowFootprints(False)
z.SetDoNotAllowPads(False)
continue
assert z.GetNetname()=='GND', 'Only the declared ground pours are expected'
# Native pour fragments can touch/overlap on tessellated boundaries. Distinct
# priorities let KiCad refill their common ground without ambiguous overlaps.
z.SetAssignedPriority(priority)
z.SetIslandRemovalMode(pcbnew.ISLAND_REMOVAL_MODE_ALWAYS)
z.SetLocalClearance(pcbnew.FromMM(.25))
# Export the actual standalone footprints so library comparison is meaningful.
lib=OUT/'tscircuit.pretty'
lib.mkdir(exist_ok=True)
for old in lib.glob('*.kicad_mod'):old.unlink()
for fp in b.GetFootprints():
item=fp.GetFPID()
item.SetLibItemName(pcbnew.UTF8(str(item.GetLibItemName())+'__'+fp.GetReference()))
fp.SetFPID(item)
pcbnew.PCB_IO_MGR.FindPlugin(pcbnew.PCB_IO_MGR.KICAD_SEXP).FootprintSave(str(lib),fp)
(OUT/'fp-lib-table').write_text('(fp_lib_table (lib (name "tscircuit")(type "KiCad")(uri "${KIPRJMOD}/tscircuit.pretty")(options "")(descr "Generated from the tscircuit source")))\n')
project={
'board':{'design_settings':{'rules':{
'min_clearance':.1,'min_track_width':.1,'min_via_diameter':.6,
'min_through_hole_diameter':.3,'min_hole_clearance':.2,'min_hole_to_hole':.2,
'min_copper_edge_clearance':.3,'min_text_height':1.0,'min_text_thickness':.15,'min_silk_clearance':.15}}},
'net_settings':{'classes':[{'name':'Default','clearance':.1,'track_width':.15,
'via_diameter':.6,'via_drill':.3,'microvia_diameter':.3,'microvia_drill':.1,
'diff_pair_width':.15,'diff_pair_gap':.15,'diff_pair_via_gap':.15,'bus_width':12,
'line_style':0,'pcb_color':'rgba(0, 0, 0, 0.000)','schematic_color':'rgba(0, 0, 0, 0.000)','wire_width':6}]}}
pcbnew.SaveBoard(str(boardpath),b)
boardpath.with_suffix('.kicad_pro').write_text(json.dumps(project,indent=2)+'\n')
# Published JLCPCB 1oz/two-layer rules, checked 2026-11-06. Component
# pad-to-pad and PTH drilling constraints remain stricter than track spacing.
boardpath.with_suffix('.kicad_dru').write_text('''(version 1)
(rule "Component pad clearance"
(condition "A.Type == 'Pad' && B.Type == 'Pad'")
(constraint clearance (min 0.15mm)))
(rule "Component plated hole spacing"
(condition "A.Pad_Type == 'Through-hole' && B.Pad_Type == 'Through-hole'")
(constraint hole_to_hole (min 0.45mm)))
(rule "Component plated drill to copper"
(condition "A.Pad_Type == 'Through-hole' || B.Pad_Type == 'Through-hole'")
(constraint hole_clearance (min 0.28mm)))
''')
# Remove only entire GND zones that KiCad identifies as electrically isolated.
# Native pour fragments can be isolated zones rather than islands of one zone;
# normal island removal alone does not eliminate these zone objects.
removed=[]
for attempt in range(3):
boardpath.with_suffix('.kicad_pro').write_text(json.dumps(project,indent=2)+'\n')
result=subprocess.run(['kicad-cli','pcb','drc',str(boardpath),'--refill-zones','--save-board',
'--format','json','--output',str(REPORT/'kicad-drc.json'),'--exit-code-violations'])
r=json.loads((REPORT/'kicad-drc.json').read_text())
isolated={item['uuid'] for v in r['violations'] if v['type']=='isolated_copper' for item in v['items']}
if not isolated:break
b=pcbnew.LoadBoard(str(boardpath));loaded_boards.append(b);count=0
for zone in list(b.Zones()):
if zone.m_Uuid.AsString() in isolated:
assert zone.GetNetname()=='GND','Only isolated ground fragments may be removed'
removed.append({'zone_uuid':zone.m_Uuid.AsString(),'net':'GND','reason':'KiCad isolated_copper'})
zone.thisown=False
b.Remove(zone);count+=1
assert count==len(isolated),'Cannot identify an isolated pour fragment'
pcbnew.SaveBoard(str(boardpath),b)
(REPORT/'pour-cleanup.json').write_text(json.dumps({'removed_isolated_ground_zones':removed},indent=2)+'\n')
assert not r['violations'] and not r['unconnected_items'], 'DRC is not clean'
final_board=pcbnew.LoadBoard(str(boardpath));loaded_boards.append(final_board)
assert routed_copper(final_board)==native_routes, 'Fabrication export must not change any native trace or via'
if args.check_only:
print(f'Independent fabrication check passed; native traces/vias unchanged. Temporary evidence: {OUT}')
sys.exit(0)
gerbers=OUT/'gerbers';gerbers.mkdir(exist_ok=True)
for old in gerbers.glob('desk-knob-*'):old.unlink()
subprocess.run(['kicad-cli','pcb','export','gerbers',str(boardpath),'--output',str(gerbers)+'/',
'--layers','F.Cu,B.Cu,F.Mask,B.Mask,F.Paste,B.Paste,F.Silkscreen,B.Silkscreen,Edge.Cuts',
'--subtract-soldermask','--check-zones'],check=True)
subprocess.run(['kicad-cli','pcb','export','drill',str(boardpath),'--output',str(gerbers)+'/',
'--format','excellon','--excellon-separate-th','--excellon-oval-format','route','--generate-map','--map-format','svg'],check=True)
subprocess.run(['kicad-cli','pcb','export','pos',str(boardpath),'--output',str(OUT/'pick-and-place.csv'),
'--format','csv','--units','mm','--side','both','--exclude-dnp'],check=True)
# Mechanical pad-only footprints have no reference and must not become placements.
position_file=OUT/'pick-and-place.csv'
with position_file.open(newline='') as f:
rows=list(csv.DictReader(f))
fields=list(rows[0]); rows=[r for r in rows if r['Ref']]
with position_file.open('w',newline='') as f:
writer=csv.DictWriter(f,fieldnames=fields);writer.writeheader();writer.writerows(rows)
assert {r['Ref'] for r in rows}==expected-dnp_refs, 'Placement population differs from the source BOM'
# Normalize generator whitespace without changing plotted geometry.
for svg in gerbers.glob('*.svg'):
svg.write_text('\n'.join(line.rstrip() for line in svg.read_text().splitlines())+'\n')
