shibosoftwaredev/f1c100s-linux-nema17-stepper-controller
A four-layer NEMA 17 motor-controller PCB combining an Allwinner F1C100S Linux computer, USB‑PD/buck power system, W5500 Ethernet, STM32 real-time motion control, DRV8825 stepper driver, protection/temperature/current telemetry, USB interfaces, and local LED/buzzer diagnostics.
- Version
- 0.4.56
- License
- unset
- Stars
- 0
scripts/finish-review-copper.py
"""Explicit clearance-polygon fanouts for this board's remaining signal links.
Retains all existing power trunks. Does not change the replay guard or router.
Full-stack stitches and each line segment are subsequently audited and imported
against the original source graph; this file cannot approve fabrication.
"""
from route_geometry import *
from shapely.ops import unary_union
from shapely.strtree import STRtree
from collections import defaultdict,deque
import subprocess,heapq
c=load_circuit('routing/review-placement.circuit.json');root=connectivity(c)
mapping=json.loads(Path('routing/net-map.json').read_text())['roots']
ports={e['pcb_port_id']:e for e in c if e['type']=='pcb_port'}
sources={e['source_trace_id']:e for e in c if e['type']=='source_trace'}
def net(source):
s=sources[source]
return mapping[root((s.get('connected_source_port_ids',[])+s.get('connected_source_net_ids',[]))[0])]
focused=bool(os.environ.get('FOCUSED_REVIEW'))
physical=json.loads(Path(os.environ.get('FINISH_PHYSICAL','routing/bulk-top-closure-partial-physical-candidate.json')).read_text())
restore=dict(removeVias=[],removeWires=[],wires=[],vias=[]) if focused else json.loads(Path('routing/review-restored-status.json').read_text())
for v in restore['removeVias']:
matches=[p for p in physical['vias'] if p['net']==v['net'] and math.dist((p['x'],p['y']),(v['x'],v['y']))<1e-7]
assert len(matches)==1;physical['vias'].remove(matches[0])
for w in restore['removeWires']:
matches=[p for p in physical['wires'] if p['net']==w['net'] and p['layer']==w['layer'] and p['points']==w['points']]
assert len(matches)==1;physical['wires'].remove(matches[0])
physical['wires']+=restore['wires'];physical['vias']+=restore['vias']
Path('routing/review-latest-seed.json').write_text(json.dumps(physical,indent=2)+'\n')
plan=json.loads(Path(os.environ.get('FINISH_PLAN','routing/bulk-top-final-plan.json')).read_text())
attempt=dict(routes=[]) if focused else json.loads(Path('reports/bulk-top-closure-attempt.json').read_text())
jobs=plan['jobs'][len(attempt['routes']):]
authored=dict(wires=[],vias=[]);completed=[]
def polygon_path(polygon,endpoints):
a,b=endpoints
if math.dist(a,b)<1e-8:return [a]
if polygon.covers(LineString([a,b])):return [a,b]
raw=subprocess.run(['/opt/homebrew/bin/bun','scripts/triangulate-review-polygon.mjs'],input=json.dumps(dict(
outer=list(polygon.exterior.coords),holes=[list(h.coords) for h in polygon.interiors])),text=True,capture_output=True,check=True)
triangles=[Polygon(xy) for xy in json.loads(raw.stdout)]
tree=STRtree(triangles)
start=next(int(i) for i in tree.query(Point(a)) if triangles[int(i)].buffer(1e-9).covers(Point(a)))
goals={int(i) for i in tree.query(Point(b)) if triangles[int(i)].buffer(1e-9).covers(Point(b))}
edges={};adj=defaultdict(list)
for i,t in enumerate(triangles):
xy=list(t.exterior.coords)
for u,v in zip(xy,xy[1:]):
key=tuple(sorted([tuple(u),tuple(v)]))
if key in edges:
j=edges[key];p=((u[0]+v[0])/2,(u[1]+v[1])/2)
adj[i].append((j,p));adj[j].append((i,p))
else:edges[key]=i
queue=[(math.dist(a,b),0,start,a)];previous={start:None};cost={start:0};found=None
while queue:
_,distance,i,position=heapq.heappop(queue)
if distance>cost[i]+1e-9:continue
if i in goals:found=i;break
for j,p in adj[i]:
total=distance+math.dist(position,p)
if total<cost.get(j,math.inf):
cost[j]=total;previous[j]=(i,p);heapq.heappush(queue,(total+math.dist(p,b),total,j,p))
assert found is not None,('No exact triangle path',a,b)
points=[b]
while found!=start:found,p=previous[found];points.append(p)
points.append(a);points.reverse()
simple=[a];i=0
allowed=polygon.buffer(1e-10)
while i<len(points)-1:
j=next(j for j in range(len(points)-1,i,-1) if allowed.covers(LineString([points[i],points[j]])))
simple.append(points[j]);i=j
assert polygon.buffer(1e-9).covers(LineString(simple))
return simple
for job in jobs:
n=net(job['source']);width=job['constraints']['min_trace_thickness'];a=job['start'];b=job['target']
for p in [a,b]:
if p['pcb_port_id'] in ports:
real=ports[p['pcb_port_id']];p.update(x=real['x'],y=real['y'],layers=real['layers'])
assert width<.299,('High-current trunks must remain explicitly engineered on outer copper',job)
blocked={l:[] for l in LAYERS};via_bad=[]
for e in c:
if e['type']=='pcb_hole':
g=geometry(e,'hole_');via_bad.append(g.buffer(.429))
for l in LAYERS:blocked[l].append(g.buffer(.2005+width/2))
if e['type'] not in ['pcb_smtpad','pcb_plated_hole']:continue
p=ports.get(e.get('pcb_port_id'));nn=mapping[root(p['source_port_id'])] if p else 'NC'
g=geometry(e,'outer_' if e['type']=='pcb_plated_hole' else '')
via_bad.append(g.buffer(.354))
if nn!=n:
for l in e.get('layers',[e.get('layer')]):blocked[l].append(g.buffer(.1005+width/2))
if e['type']=='pcb_plated_hole':
h=geometry(e,'hole_');via_bad.append(h.buffer(.429))
if nn!=n:
for l in LAYERS:blocked[l].append(h.buffer((.3005 if l.startswith('inner') else .2805)+width/2))
for w in physical['wires']:
if w['net']==n:continue
g=LineString(w['points']).buffer(w['width']/2)
blocked[w['layer']].append(g.buffer(.1005+width/2));via_bad.append(g.buffer(.354))
for v in physical['vias']:
p=Point(v['x'],v['y']);via_bad.append(p.buffer(.554))
if v['net']!=n:
for l in LAYERS:blocked[l].append(p.buffer(.354+width/2))
for x in [-15.5,15.5]:
for y in [-15.5,15.5]:
p=Point(x,y);via_bad.append(p.buffer(3.1))
blocked['top'].append(p.buffer(2.8505+width/2));blocked['bottom'].append(p.buffer(2.6005+width/2))
edge=20.9495-width/2
regions=[]
for l in job['constraints'].get('allowed_layers',LAYERS):
free=box(-edge,-edge,edge,edge).difference(unary_union(blocked[l]))
regions += [(l,g) for g in (list(free.geoms) if hasattr(free,'geoms') else [free]) if g.area>1e-12]
tree=STRtree([g for l,g in regions]);adj=defaultdict(list)
available=box(-20.7,-20.7,20.7,20.7).difference(unary_union(via_bad))
spaces=list(available.geoms) if hasattr(available,'geoms') else [available]
spaces.sort(key=lambda g:g.distance(Point(a['x'],a['y']))+g.distance(Point(b['x'],b['y'])))
for g in spaces:
if g.is_empty:continue
q=g.representative_point();xy=(q.x,q.y)
touching=[int(i) for i in tree.query(q) if regions[int(i)][1].covers(q)]
for i in touching:
for j in touching:
if i!=j:adj[i].append((j,xy))
aa=Point(a['x'],a['y']);bb=Point(b['x'],b['y'])
own=[]
for w in physical['wires']:
if w['net']==n:
line=LineString(w['points']);own.append(dict(shape=line.buffer(w['width']/2),center=line,layers=[w['layer']]))
for v in physical['vias']:
if v['net']==n:
p=Point(v['x'],v['y']);own.append(dict(shape=p.buffer(.225),center=p,layers=LAYERS))
for e in c:
if e['type'] not in ['pcb_smtpad','pcb_plated_hole'] or e.get('pcb_port_id') not in ports:continue
p=ports[e['pcb_port_id']]
if mapping[root(p['source_port_id'])]!=n:continue
g=geometry(e,'outer_' if e['type']=='pcb_plated_hole' else '')
own.append(dict(shape=g,center=g.buffer(-width/2-.01),layers=e.get('layers',[e.get('layer')])))
own_tree=STRtree([o['shape'] for o in own]);todo=[int(i) for i in own_tree.query(bb) if own[int(i)]['shape'].buffer(1e-8).covers(bb) and set(own[int(i)]['layers'])&set(b['layers'])];target_indices=set()
while todo:
i=todo.pop()
if i in target_indices:continue
target_indices.add(i);o=own[i]
for j in own_tree.query(o['shape'].buffer(1e-8)):
j=int(j);p=own[j]
if j not in target_indices and set(o['layers'])&set(p['layers']) and o['shape'].distance(p['shape'])<1e-8:todo.append(j)
assert target_indices,('Landing must belong to actual retained copper',job)
if any(own[i]['shape'].buffer(1e-8).covers(aa) and set(own[i]['layers'])&set(a['layers']) for i in target_indices):
completed.append(dict(source=job['source'],net=n,alreadyConnectedByActualCopper=True))
print(json.dumps(completed[-1]),flush=True)
continue
todo=[int(i) for i in own_tree.query(aa) if own[int(i)]['shape'].buffer(1e-8).covers(aa) and set(own[int(i)]['layers'])&set(a['layers'])];source_indices=set()
while todo:
i=todo.pop()
if i in source_indices:continue
source_indices.add(i);o=own[i]
for j in own_tree.query(o['shape'].buffer(1e-8)):
j=int(j);p=own[j]
if j not in source_indices and set(o['layers'])&set(p['layers']) and o['shape'].distance(p['shape'])<1e-8:todo.append(j)
assert source_indices,('Start must belong to actual retained copper',job)
starts={}
for j in source_indices:
o=own[j]
for i in tree.query(o['center']):
i=int(i);l,g=regions[i]
if l not in o['layers']:continue
intersection=g.intersection(o['center'])
if intersection.is_empty:continue
q=intersection.representative_point();xy=(q.x,q.y)
if i not in starts or math.dist((b['x'],b['y']),xy)<math.dist((b['x'],b['y']),starts[i]):starts[i]=xy
goals={}
for j in target_indices:
o=own[j]
for i in tree.query(o['center']):
i=int(i);l,g=regions[i]
if l not in o['layers']:continue
intersection=g.intersection(o['center'])
if intersection.is_empty:continue
q=intersection.representative_point();xy=(q.x,q.y)
if i not in goals or math.dist((a['x'],a['y']),xy)<math.dist((a['x'],a['y']),goals[i]):goals[i]=xy
queue=deque(starts);previous={i:None for i in starts};found=None
while queue:
i=queue.popleft()
if i in goals:found=i;break
for j,xy in adj[i]:
if j not in previous:previous[j]=(i,xy);queue.append(j)
assert found is not None,('No measured layer path',job,starts,goals)
landing=goals[found]
transitions=[];end=found
while previous[found] is not None:
old,xy=previous[found];transitions.append((old,found,xy));found=old
transitions.reverse();position=starts[found];drawn=[]
for old,new,xy in transitions+[(end,None,landing)]:
path=polygon_path(regions[old][1],(position,xy));layer=regions[old][0]
if len(path)>1:
w=dict(net=n,layer=layer,width=width,points=path);physical['wires'].append(w);authored['wires'].append(w);drawn.append(w)
if new is not None:
v=dict(net=n,x=xy[0],y=xy[1]);physical['vias'].append(v);authored['vias'].append(v)
position=xy
completed.append(dict(source=job['source'],net=n,segments=sum(len(w['points'])-1 for w in drawn),lengthMm=sum(LineString(w['points']).length for w in drawn),vias=len(transitions)))
print(json.dumps(completed[-1]),flush=True)
if os.environ.get('SWAP_PROGRESS'):
Path('routing/swap-progress-physical.json').write_text(json.dumps(physical,indent=2)+'\n')
Path('reports/swap-progress.json').write_text(json.dumps(dict(complete=False,notInstalled=True,routes=completed),indent=2)+'\n')
Path('routing/review-finished-copper.json').write_text(json.dumps(authored,indent=2)+'\n')
Path('routing/bulk-top-closure-physical-candidate.json').write_text(json.dumps(physical,indent=2)+'\n')
num=lambda x:str(round(float(x)*10000,4))
rows=['(session local_rework (routes (resolution um 10) (network_out']
for n in sorted({w['net'] for w in physical['wires']}|{v['net'] for v in physical['vias']}):
rows.append(f'(net {n}')
for w in physical['wires']:
if w['net']==n:rows.append(f"(wire (path {w['layer']} {num(w['width'])} "+' '.join(num(v) for p in w['points'] for v in p)+'))')
for v in physical['vias']:
if v['net']==n:rows.append(f"(via VIA {num(v['x'])} {num(v['y'])})")
rows.append(')')
rows.append(')))')
Path('routing/bulk-top-closure-candidate.ses').write_text('\n'.join(rows)+'\n')
Path('reports/bulk-top-closure-closure.json').write_text(json.dumps(dict(complete=True,notInstalled=True,routes=completed,method='explicit clearance-polygon fanouts; independent import required'),indent=2)+'\n')
print(json.dumps(dict(complete=True,wires=len(physical['wires']),vias=len(physical['vias']))))