imrishabh18/rp2040-motor-controller
An RP2040-based NEMA17 stepper-motor controller with USB-C programming and PD motor-power negotiation, DRV8825 dual H-bridge drive, current/temperature telemetry, protected power filtering, status RGB LED, buzzer alarm, and optional magnetic encoder.
- Version
- 1.0.41
- License
- unset
- Stars
- 3
scripts/route-local.py
"""Reroute one stored connection with clearance-aware, through-via-only grid search.
Requires numpy and shapely. Always re-render the plan and run the independent DRC
and Gerber checks afterward. No net endpoints or manufacturing rules are changed.
"""
import json,sys,math,heapq,time
from pathlib import Path
import numpy as np
import shapely
from shapely.geometry import Point,LineString,Polygon,box
from shapely.affinity import rotate,translate
from shapely.ops import unary_union, nearest_points
from copper_geometry import trace_segments
folder=Path(sys.argv[1]); target=sys.argv[2]; width=float(sys.argv[3]) if len(sys.argv)>3 else .15
report_dir=Path(next((a.split('=',1)[1] for a in sys.argv if a.startswith('--report-dir=')),'reports'))
report_dir.mkdir(parents=True,exist_ok=True)
plan=json.loads((folder/'route-plan.json').read_text());j=json.loads((folder/'circuit.json').read_text())
via_diameter=plan['expectedInput'].get('minViaPadDiameter',.5)
via_hole_diameter=plan['expectedInput'].get('minViaHoleDiameter',.3)
via_copper_clearance=plan['expectedInput'].get('minViaEdgeToPadEdgeClearance',.125)
pt={t['pcb_trace_id']:t for t in plan['traces']}; ct={t['pcb_trace_id']:t for t in j if t['type']=='pcb_trace'}
trace=pt.get(target) or next(t for t in plan['traces'] if t['pcb_trace_id'].endswith(target));target=trace['pcb_trace_id']
prefix=[]
if '--preserve-start-fanout' in sys.argv:
via_index=next(i for i,p in enumerate(trace['route']) if p['route_type']=='via')
prefix=trace['route'][:via_index+1]
trace['route']=trace['route'][via_index+1:]
source_defs={e['source_trace_id']:e for e in j if e['type']=='source_trace'}
thermal_via_sources={e['source_trace_id'] for e in j if e['type']=='source_trace' and e.get('display_name','').startswith(('DRV_EP_THERM_', 'DRV_IMPORTED_EP_'))}
component_names={e['source_component_id']:e['name'] for e in j if e['type']=='source_component'}
shunt_source_ports={e['source_port_id'] for e in j if e['type']=='source_port' and component_names.get(e['source_component_id'],'').startswith('R_PHASE_')}
shunt_pcb_ports={e['pcb_port_id'] for e in j if e['type']=='pcb_port' and e.get('source_port_id') in shunt_source_ports}
amp_source_ports={e['source_port_id'] for e in j if e['type']=='source_port' and component_names.get(e['source_component_id'],'').startswith('U_CURRENT_')}
amp_pcb_ports={e['pcb_port_id'] for e in j if e['type']=='pcb_port' and e.get('source_port_id') in amp_source_ports}
is_motor_path=('--outer-only' in sys.argv and width>=.35) or source_defs.get(ct.get(target,trace).get('source_trace_id'),{}).get('name') in ['MOTOR_A_PLUS','MOTOR_A_MINUS','MOTOR_B_PLUS','MOTOR_B_MINUS','CURRENT_A_WINDING','CURRENT_B_WINDING']
parent={}
def root(x):
parent.setdefault(x,x)
if parent[x]!=x:parent[x]=root(parent[x])
return parent[x]
for e in j:
ids=e.get('connected_source_port_ids',[])+e.get('connected_source_net_ids',[]) if e['type']=='source_trace' else e.get('source_port_ids',[]) if e['type']=='source_component_internal_connection' else []
for i in ids[1:]:parent[root(i)]=root(ids[0])
sp={e['source_trace_id']:root((e.get('connected_source_port_ids',[])+e.get('connected_source_net_ids',[]))[0]) for e in j if e['type']=='source_trace' and (e.get('connected_source_port_ids') or e.get('connected_source_net_ids'))}
ports={e['pcb_port_id']:root(e['source_port_id']) for e in j if e['type']=='pcb_port' and e.get('source_port_id')}
def tnet(t):
if t.get('source_trace_id') in sp:return sp[t['source_trace_id']]
if t.get('source_net_id'):return root(t['source_net_id'])
if t.get('pcb_port_id') in ports:return ports[t['pcb_port_id']]
for p in t.get('connectsTo',[]):
if p in ports:return ports[p]
name=t.get('connection_name','')
if name.startswith('source_net_') and name in parent:return root(name)
old=ct.get(t.get('pcb_trace_id'))
if old is not None and old is not t:return tnet(old)
return t.get('pcb_trace_id',t.get('pcb_via_id'))
protected=set(next((a.split("=",1)[1].split(",") for a in sys.argv if a.startswith("--protected=")),[]))
soft_routing='--soft-routing' in sys.argv
soft_penalty=float(next((a.split('=',1)[1] for a in sys.argv if a.startswith('--soft-penalty=')),'120'))
ripup=set(next((a.split('=',1)[1].split(',') for a in sys.argv if a.startswith('--ripup=')),[]))
start,end=trace['route'][0],trace['route'][-1]
endpoint_points=[Point(p['x'],p['y']) for p in (start,end)]
endpoint_holes=[]
all_drills=[]
via_only_obstacles=[]
manual_same_net_vias=[]
net=tnet(trace);layers=['top','inner1','inner2','bottom'];shapes={l:[] for l in layers};owners={l:[] for l in layers};kinds={l:[] for l in layers};holes=[]
def add(l,g,owner,kind='trace'):shapes[l].append(g);owners[l].append(owner);kinds[l].append(kind)
def pad(e,prefix=''):
shape=e.get('shape',e.get('hole_shape','circle'));x=e.get('x',0);y=e.get('y',0)
if shape=='circle':g=Point(0,0).buffer(e.get(prefix+'diameter',e.get('radius',0)*2)/2)
elif 'polygon' in shape:g=Polygon([(p['x'],p['y']) for p in e['points']])
else:
w,h=e[prefix+'width'],e[prefix+'height'];r=min(w,h)/2 if 'pill' in shape else e.get('corner_radius',0)
if r:
g=box(-w/2+r,-h/2+r,w/2-r,h/2-r).buffer(r) if w>2*r and h>2*r else LineString([(-w/2+r,-h/2+r),(w/2-r,h/2-r)]).buffer(r)
else:g=box(-w/2,-h/2,w/2,h/2)
return translate(rotate(g,e.get('ccw_rotation',0),origin=(0,0)),x,y)
for e in j:
typ=e['type']
if typ=='pcb_smtpad':
if ports.get(e.get('pcb_port_id'))!=net or (is_motor_path and e.get('pcb_port_id') in amp_pcb_ports):add(e['layer'],pad(e).buffer(.05) if is_motor_path else pad(e),e['pcb_smtpad_id'])
else:via_only_obstacles.append(pad(e))
elif typ=='pcb_plated_hole':
all_drills.append(pad(e,'hole_').buffer(.45+via_hole_diameter/2+.008))
if ports.get(e.get('pcb_port_id'))!=net:
for l in e['layers']:add(l,pad(e,'outer_').buffer(.05) if is_motor_path else pad(e,'outer_'),e['pcb_plated_hole_id'])
else:
hole=pad(e,'hole_')
# A wire terminates at the plated barrel centre; only new vias must avoid its drill.
if any(hole.covers(p) for p in endpoint_points):endpoint_holes.append(hole)
else:holes.append(hole)
elif typ=='pcb_hole':
# The 0.105 mm wire guard plus this buffer enforces 0.2 mm to NPTHs.
holes.append((pad(e,'hole_') if 'hole_diameter' in e else pad(e)).buffer(.1))
elif typ=='pcb_via' and e.get('pcb_trace_id') not in pt:
enet=tnet(ct[e['pcb_trace_id']]) if e.get('pcb_trace_id') in ct else tnet(e)
if enet==net:
manual_same_net_vias.append({'route_type':'via','x':e['x'],'y':e['y'],'via_diameter':e['outer_diameter'],'via_hole_diameter':e['hole_diameter']})
continue
for l in layers:
# Preserve a solid 0.2-mm-clearance ground pour around in-pad thermal
# annuli on every heat-spreading layer, beyond mere electrical clearance.
radius=max(e['outer_diameter'],e['hole_diameter']+.2)/2
if e.get('source_trace_id') in thermal_via_sources and l!='top':radius+=.105
add(l,Point(e['x'],e['y']).buffer(radius),e['pcb_via_id'],'via')
same_net_vias=list(manual_same_net_vias)
shunt_copper=unary_union([pad(e) for e in j if e['type']=='pcb_smtpad' and e.get('pcb_port_id') in shunt_pcb_ports])
foreign_via_barriers=[]
protected_via_barriers=[]
reserve_fanouts=set(next((a.split('=',1)[1].split(',') for a in sys.argv if a.startswith('--reserve-fanouts=')),[]))
# Include freshly added candidate routes before the next full core render.
# Their explicit source/net metadata is sufficient to classify obstacles.
for old in [*(t for id,t in ct.items() if id in pt or id.startswith('pcb_trace_')), *(t for id,t in pt.items() if id not in ct)]:
if old['pcb_trace_id']!=target and old['pcb_trace_id'] in reserve_fanouts and old['pcb_trace_id'] not in protected and tnet(pt.get(old['pcb_trace_id'],old))!=net:
# Keep pending power-pad escapes available while negotiating the long runs.
# These fixed landing sites must not be erased by --ripup/--soft-routing.
# Once a route is protected, its actual copper below is authoritative;
# obsolete baseline fanouts must not block the newly available corridors.
vv=[i for i,p in enumerate(old['route']) if p['route_type']=='via']
if vv:
owner='fixed_fanout:'+old['pcb_trace_id']
for route in [old['route'][:vv[0]],old['route'][vv[-1]+1:]]:
for a,b,l,w in trace_segments(route):add(l,LineString([a,b]).buffer(w/2),owner)
for i in set([vv[0],vv[-1]]):
v=old['route'][i]
barrier=Point(v['x'],v['y']).buffer((v.get('via_hole_diameter',via_hole_diameter)+via_hole_diameter)/2+.208)
foreign_via_barriers.append(barrier);protected_via_barriers.append(barrier)
for l in layers:add(l,Point(v['x'],v['y']).buffer(v.get('via_diameter',via_diameter)/2),owner,'via')
if old['pcb_trace_id']==target or old['pcb_trace_id'] in ripup:continue
t=pt.get(old['pcb_trace_id'],old)
if tnet(pt.get(old['pcb_trace_id'],old))==net:
if is_motor_path and '_SENSE_' in source_defs.get(old.get('source_trace_id'),{}).get('name',''):
# A Kelvin sense branch may meet the load-current path only inside its
# shunt terminal, never along the thin sense lead or amplifier input pad.
for a,b,layer,segment_width in trace_segments(t['route']):
g=LineString([a,b]).buffer(segment_width/2).difference(shunt_copper.buffer(.02))
if not g.is_empty:add(layer,g,t['pcb_trace_id'])
# Same-net wires may merge, but independently drilled vias still need spacing.
for a in t['route']:
if a['route_type']=='via':same_net_vias.append(a)
continue
for a,b,layer,segment_width in trace_segments(t['route']):
add(layer,LineString([a,b]).buffer(segment_width/2),t['pcb_trace_id'])
for a in t['route']:
if a['route_type']=='via':
# The 0.2 mm rule measures drill edges, not the copper annuli. Copper
# separation is independently enforced by the inflated obstacle masks.
vbar=Point(a['x'],a['y']).buffer((a.get('via_hole_diameter',via_hole_diameter)+via_hole_diameter)/2+.208)
foreign_via_barriers.append(vbar)
if t['pcb_trace_id'] in protected:protected_via_barriers.append(vbar)
for l in layers:add(l,Point(a['x'],a['y']).buffer(max(a.get('via_diameter',via_diameter),a.get('via_hole_diameter',.3)+.2)/2),t['pcb_trace_id'],'via')
if '--expand-only' in sys.argv:
if '--subdivide' in sys.argv:
points=[];r=trace['route']
for i,a in enumerate(r):
points.append(a)
if i+1==len(r):continue
b=r[i+1]
if a['route_type']!=b['route_type'] or a['route_type']!='wire' or a['layer']!=b['layer']:continue
n=math.ceil(math.hypot(a['x']-b['x'],a['y']-b['y'])/.075)
for k in range(1,n):points.append(dict(route_type='wire',x=a['x']+(b['x']-a['x'])*k/n,y=a['y']+(b['y']-a['y'])*k/n,layer=a['layer'],width=a['width']))
trace['route']=points
changed=0
obstacles={l:unary_union(shapes[l]+holes) for l in layers}
for i,p in enumerate(trace['route']):
if p['route_type']!='wire' or p['width']>=width:continue
b=trace['route'][i+1] if i+1<len(trace['route']) else p
if b['route_type'] not in ('wire','via') or (b['route_type']=='wire' and b['layer']!=p['layer']):b=p
g=Point(p['x'],p['y']) if (p['x'],p['y'])==(b['x'],b['y']) else LineString([(p['x'],p['y']),(b['x'],b['y'])])
distance=g.distance(obstacles[p['layer']]);available=math.floor((2*(distance-.105)+1e-9)/.005)*.005
new_width=min(width,available)
if new_width>p['width']+1e-8:p['width']=round(new_width,6);changed+=1
(folder/'route-plan.json').write_text(json.dumps(plan,indent=2));print('Expanded',target,changed,'vertices toward',width,flush=True);sys.exit(0)
if '--audit-route' in sys.argv:
conflicts=set()
for a,b,l,w in trace_segments(trace['route']):
segment=LineString([a,b])
for g,o in zip(shapes[l],owners[l]):
if segment.distance(g)<.1+w/2-1e-6:conflicts.add((l,o,round(segment.distance(g)-w/2,6),a,b))
for a in trace['route']:
if a['route_type']!='via':continue
q=Point(a['x'],a['y'])
for l in layers:
for g,o,k in zip(shapes[l],owners[l],kinds[l]):
gap=.125 if k=='via' or o.startswith(('pcb_smtpad_','pcb_plated_hole_')) else .1
extra=.05 if is_motor_path and o.startswith(('pcb_smtpad_','pcb_plated_hole_')) else 0
if q.distance(g)<a.get('via_diameter',.5)/2+gap-extra-1e-6:conflicts.add((l,o))
print('Route conflicts',sorted(conflicts),flush=True);sys.exit(0)
if any(a.startswith('--probe=') for a in sys.argv):
x,y=map(float,next(a for a in sys.argv if a.startswith('--probe=')).split('=')[1].split(','));q=Point(x,y)
print('PROBE',sorted(set((l,oid,round(g.distance(q),3)) for l in layers for g,oid in zip(shapes[l],owners[l]) if g.distance(q)<.383)),flush=True)
sys.exit(0)
start,end=trace['route'][0],trace['route'][-1]
if '--debug' in sys.argv:
for p in trace['route']:
if p['route_type']=='via':
pt0=Point(p['x'],p['y']);near=sorted([(g.distance(pt0),l,g.centroid.x,g.centroid.y) for l,ss in shapes.items() for g in ss])[:3];print('Original via',p['x'],p['y'],'nearby',near,flush=True)
step=float(next((a.split('=',1)[1] for a in sys.argv if a.startswith('--grid-step=')),'.05'));clearance=.105
via_cost=float(next((a.split('=',1)[1] for a in sys.argv if a.startswith('--via-cost=')),'80'))
escape_width=float(next((a.split('=',1)[1] for a in sys.argv if a.startswith('--escape-width=')),str(width)))
escape_radius=float(next((a.split('=',1)[1] for a in sys.argv if a.startswith('--escape-radius=')),'1'))
sx,sy=start['x'],start['y'];ex,ey=end['x'],end['y']
# Keep the grid aligned with the starting pad to preserve fine-pitch escapes.
minx=sx+math.floor((-21-sx)/step)*step;miny=sy+math.floor((-21-sy)/step)*step
nx=math.ceil((21-minx)/step)+1;ny=math.ceil((21-miny)/step)+1
xs=minx+np.arange(nx)*step;ys=miny+np.arange(ny)*step
xx,yy=np.meshgrid(xs,ys);points=shapely.points(xx.ravel(),yy.ravel())
history_file=report_dir/'route-congestion-history.npz'
history=np.load(history_file)['cost'] if history_file.exists() else np.zeros((4,481,481),dtype=np.uint16)
hix=np.clip(np.rint((xs+24)/.1).astype(int),0,480);hiy=np.clip(np.rint((ys+24)/.1).astype(int),0,480)
history_cost=[history[z][np.ix_(hiy,hix)] if soft_routing else np.zeros((ny,nx),dtype=np.uint16) for z in range(4)]
board=next(e for e in j if e['type']=='pcb_board');outline=board.get('outline')
boundary=Polygon([(p['x'],p['y']) for p in outline]) if outline else box(-21.15,-21.16,21.15,21.16)
inside=shapely.contains(boundary.buffer(-.3-width/2),points).reshape(ny,nx)
mask=[];obstacles=[];soft_masks=[];soft_obstacles=[]
for l in layers:
hard=[g for g,o in zip(shapes[l],owners[l]) if not soft_routing or o not in pt or o in protected]
soft=[g for g,o in zip(shapes[l],owners[l]) if soft_routing and o in pt and o not in protected]
g=unary_union(hard+holes);obstacles.append(g)
sg=unary_union(soft);soft_obstacles.append(sg)
soft_inflated=sg.buffer(clearance+width/2+.008);shapely.prepare(soft_inflated)
soft_masks.append(shapely.contains(soft_inflated,points).reshape(ny,nx))
inflated=g.buffer(clearance+width/2+.008);shapely.prepare(inflated)
allowed=~shapely.contains(inflated,points).reshape(ny,nx)
if escape_width<width:
near_endpoint=((xx-sx)**2+(yy-sy)**2<escape_radius**2)|((xx-ex)**2+(yy-ey)**2<escape_radius**2)
narrow=g.buffer(clearance+escape_width/2+.008);shapely.prepare(narrow)
allowed|=near_endpoint & ~shapely.contains(narrow,points).reshape(ny,nx)
mask.append(inside & allowed)
same_net_drill_barriers=[Point(p['x'],p['y']).buffer((p.get('via_hole_diameter',.3)+via_hole_diameter)/2+.208) for p in same_net_vias]
# Sub-nanometre snapping prevents GEOS ring-edge failures in the inflated
# search mask. It is far below the 0.008 mm routing guard and does not alter
# the saved copper or replace the exact-clearance/manufacturing checks.
# Pad and trace clearances are distinct: do not inflate all traces by the
# larger via-to-pad rule, which needlessly closes dense legal via escapes.
via_keepouts=[]
for l in layers:
for g,o,k in zip(shapes[l],owners[l],kinds[l]):
if soft_routing and o in pt and o not in protected:continue
gap=via_copper_clearance if k=='via' or o.startswith(('pcb_smtpad_', 'pcb_plated_hole_')) else .1
# The extra 0.05 mm around SMT pads applies to motor traces only.
# Vias retain their independently specified 0.125 mm pad clearance.
pad_trace_extra=.05 if is_motor_path and o.startswith(('pcb_smtpad_','pcb_plated_hole_')) else 0
via_keepouts.append(g.buffer(via_diameter/2+gap+.008-pad_trace_extra))
via_keepouts += [g.buffer(via_diameter/2+via_copper_clearance+.008) for g in holes+endpoint_holes+via_only_obstacles]
viaArea=unary_union(via_keepouts).union(unary_union(all_drills+same_net_drill_barriers+(protected_via_barriers if soft_routing else foreign_via_barriers)),grid_size=1e-7);shapely.prepare(viaArea)
viaMask=inside & ~shapely.contains(viaArea,points).reshape(ny,nx)
softViaArea=unary_union(soft_obstacles).buffer(via_diameter/2+via_copper_clearance+.008).union(unary_union(foreign_via_barriers),grid_size=1e-7)
shapely.prepare(softViaArea)
softViaMask=shapely.contains(softViaArea,points).reshape(ny,nx) if soft_routing else np.zeros((ny,nx),dtype=bool)
# Preserve legal existing via sites even when they fall between grid samples.
viaOverrides={}
existing_pad_obstacles=[];existing_trace_obstacles=[]
for l in layers:
for g,o in zip(shapes[l],owners[l]):
if soft_routing and o in pt and o not in protected:continue
(existing_pad_obstacles if o.startswith(('pcb_smtpad_','pcb_plated_hole_')) else existing_trace_obstacles).append(g)
existing_obstacles=[(unary_union(existing_pad_obstacles+holes+endpoint_holes),via_copper_clearance),(unary_union(existing_trace_obstacles),.1)]
specified_vias=[]
for arg in sys.argv:
if arg.startswith('--via-site='):
vx,vy=map(float,arg.split('=',1)[1].split(','))
q=Point(vx,vy)
if any(q.distance(g)<via_diameter/2+via_copper_clearance+.002 for g in via_only_obstacles):raise RuntimeError('Specified via site overlaps a same-net pad')
specified_vias.append(dict(route_type='via',x=vx,y=vy,via_diameter=via_diameter,via_hole_diameter=via_hole_diameter))
for p in trace['route']+same_net_vias+specified_vias:
if p['route_type']!='via':continue
if any(1e-7<math.hypot(p['x']-v['x'],p['y']-v['y'])<(v.get('via_hole_diameter',.3)+via_hole_diameter)/2+.2 for v in same_net_vias):continue
# Reusing an already drilled same-net via does not create another drill
# or a new via-in-pad. Check its actual diameter against foreign copper.
q=Point(p['x'],p['y']);existing_diameter=p.get('via_diameter',via_diameter)
clear=not any(g.covers(q) for g in all_drills+foreign_via_barriers)
if any(q.distance(g)<existing_diameter/2+gap-1e-7 for g,gap in existing_obstacles):clear=False
if clear:
i=round((p['x']-minx)/step);k=round((p['y']-miny)/step)
viaMask[k,i]=True;viaOverrides[i,k]=(p['x'],p['y'],existing_diameter,p.get('via_hole_diameter',via_hole_diameter))
move_via=next((a.split('=',1)[1] for a in sys.argv if a.startswith('--move-via=')),None)
if move_via:
import copy
vx,vy=map(float,move_via.split(','));choices=np.argwhere(viaMask & (np.hypot(xx-vx,yy-vy)<1.5))
choices=sorted(choices,key=lambda v:math.hypot(xs[v[1]]-vx,ys[v[0]]-vy))
for k,i in choices:
x,y=viaOverrides.get((i,k),(float(xs[i]),float(ys[k])))[:2]
if any(q['route_type']=='via' and math.hypot(q['x']-vx,q['y']-vy)>1e-6 and math.hypot(q['x']-x,q['y']-y)<.51 for q in trace['route']):continue
trial=copy.deepcopy(trace['route'])
for q in trial:
if 'x' in q and math.hypot(q['x']-vx,q['y']-vy)<1e-6:q.update(x=x,y=y)
valid=True
for a,b,l,w in trace_segments(trial):
if min(math.hypot(a[0]-x,a[1]-y),math.hypot(b[0]-x,b[1]-y))>1e-6:continue
if LineString([a,b]).distance(obstacles[layers.index(l)])<clearance+w/2+.003:valid=False;break
if valid:
trace['route']=trial;(folder/'route-plan.json').write_text(json.dumps(plan,indent=2));print('Moved via',target,(vx,vy),'to',(x,y),flush=True);sys.exit(0)
raise RuntimeError('No local via relocation satisfies copper clearance')
if '--ground-stitch' in sys.argv:
# Terminate a local ground branch at a through via into the ground pours.
# The copper-connectivity audit must verify the via joins the main ground.
if not any(e['type']=='source_net' and e.get('name')=='GND' and root(e['source_net_id'])==net for e in j):raise RuntimeError('Ground stitching requires a GND trace')
choices=np.argwhere(viaMask & (np.hypot(xx-sx,yy-sy)<2.5))
choices=sorted(choices,key=lambda v:math.hypot(xs[v[1]]-sx,ys[v[0]]-sy))
for k,i in choices:
x,y=viaOverrides.get((i,k),(float(xs[i]),float(ys[k])))[:2];line=LineString([(sx,sy),(x,y)])
if line.distance(obstacles[layers.index(start['layer'])])<clearance+width/2+.005:continue
trace['route']=[dict(route_type='wire',x=sx,y=sy,layer=start['layer'],width=width),dict(route_type='wire',x=x,y=y,layer=start['layer'],width=width),dict(route_type='via',x=x,y=y,from_layer='top',to_layer='bottom',via_diameter=via_diameter,via_hole_diameter=via_hole_diameter),dict(route_type='wire',x=x,y=y,layer=('top' if start['layer']=='bottom' else 'bottom'),width=width)]
trace['connectsTo']=[e['pcb_port_id'] for e in j if e['type']=='pcb_port' and math.hypot(e['x']-sx,e['y']-sy)<1e-5 and start['layer'] in e['layers']]
(folder/'route-plan.json').write_text(json.dumps(plan,indent=2))
print('Ground stitch',target,'at',x,y,'branch length',line.length,flush=True);sys.exit(0)
# A bent local escape may reach a legal via even when no straight ray does.
si,sj=round((sx-minx)/step),round((sy-miny)/step);z=layers.index(start['layer'])
queue=[(0,si,sj)];distance={(si,sj):0};previous={};found=None
while queue:
cost,i,k=heapq.heappop(queue)
if cost!=distance[i,k]:continue
if cost>2.3:break
if viaMask[k,i] and (i,k)!=(si,sj):found=(i,k);break
for di,dk in [(a,b) for a in [-1,0,1] for b in [-1,0,1] if a or b]:
ni,nk=i+di,k+dk
if not (0<=ni<nx and 0<=nk<ny and mask[z][nk,ni]):continue
if di and dk and not(mask[z][k,ni] and mask[z][nk,i]):continue
nc=cost+step*math.hypot(di,dk)
if nc<distance.get((ni,nk),math.inf):distance[ni,nk]=nc;previous[ni,nk]=(i,k);heapq.heappush(queue,(nc,ni,nk))
if found:
path=[found]
while path[-1]!=(si,sj):path.append(previous[path[-1]])
path.reverse();route=[dict(route_type='wire',x=float(xs[i]),y=float(ys[k]),layer=start['layer'],width=width) for i,k in path]
route[0].update(x=sx,y=sy);i,k=found;x,y=viaOverrides.get(found,(float(xs[i]),float(ys[k])))[:2];route[-1].update(x=x,y=y)
route.extend([dict(route_type='via',x=x,y=y,from_layer='top',to_layer='bottom',via_diameter=via_diameter,via_hole_diameter=via_hole_diameter),dict(route_type='wire',x=x,y=y,layer=('top' if start['layer']=='bottom' else 'bottom'),width=width)])
trace['route']=route;trace['connectsTo']=[e['pcb_port_id'] for e in j if e['type']=='pcb_port' and math.hypot(e['x']-sx,e['y']-sy)<1e-5 and start['layer'] in e['layers']]
(folder/'route-plan.json').write_text(json.dumps(plan,indent=2));print('Ground stitch bent escape',target,'length',distance[found],flush=True);sys.exit(0)
raise RuntimeError('No legal local ground stitching site')
if '--top-only' in sys.argv:
viaMask[:]=False
for z in range(1,4):mask[z][:]=False
if '--outer-only' in sys.argv:
for z in [1,2]:mask[z][:]=False
if '--bottom-only' in sys.argv:
viaMask[:]=False
for z in [0,1,2]:mask[z][:]=False
si,sj=round((sx-minx)/step),round((sy-miny)/step);ei,ej=round((ex-minx)/step),round((ey-miny)/step)
sl=layers.index(start['layer']);el=layers.index(end['layer']);mask[sl][sj,si]=True
# A destination grid point must also have a clear short segment to the exact pad.
goals=set()
for i in range(ei-2,ei+3):
for k in range(ej-2,ej+3):
if mask[el][k,i] and LineString([(xs[i],ys[k]),(ex,ey)]).distance(obstacles[el])>=clearance+escape_width/2:goals.add((i,k,el))
print('Net',net,'start',start,'end',end,'goal cells',len(goals),'start distance',Point(sx,sy).distance(obstacles[sl]),'end distance',Point(ex,ey).distance(obstacles[el]),flush=True)
if '--debug' in sys.argv:
from collections import deque
cells={(si,sj)};qq=deque(cells);vv=[]
while qq:
i,k=qq.popleft()
if viaMask[k,i]:vv.append((float(xs[i]),float(ys[k])))
for a,b in [(i-1,k),(i+1,k),(i,k-1),(i,k+1)]:
if 0<=a<nx and 0<=b<ny and mask[sl][b,a] and (a,b) not in cells:cells.add((a,b));qq.append((a,b))
print('Start top region',len(cells),'via candidates',len(vv),vv[:5],flush=True)
candidates=[];allCopper=unary_union(obstacles)
for i,k in cells:
p=Point(xs[i],ys[k])
if p.distance(obstacles[sl])<.33:continue
candidates.append((p.distance(allCopper),i,k))
for d,i,k in sorted(candidates,reverse=True)[:3]:
p=Point(xs[i],ys[k]);near=sorted((g.distance(p),l,oid) for l in layers for g,oid in zip(shapes[l],owners[l]))[:4]
print('Potential via',float(xs[i]),float(ys[k]),d,'blockers',near,flush=True)
if not goals:raise RuntimeError('No clear destination escape')
# Fail before an expensive board-wide search when the destination's local
# copper corridor cannot reach any legal via or the starting terminal.
from collections import deque
reachable={(i,k) for i,k,z in goals};flood=deque(reachable);escape_found=False
while flood:
i,k=flood.popleft()
if (sl==el and (i,k)==(si,sj)) or (viaMask[k,i] and any(mask[z][k,i] for z in range(4) if z!=el)):
escape_found=True;break
for di,dk,_ in [(1,0,1),(-1,0,1),(0,1,1),(0,-1,1)]:
ni,nk=i+di,k+dk
if 0<=ni<nx and 0<=nk<ny and mask[el][nk,ni] and (ni,nk) not in reachable:
reachable.add((ni,nk));flood.append((ni,nk))
if not escape_found:
candidates=[];allCopper=unary_union(obstacles)
for i,k in reachable:
p=Point(xs[i],ys[k])
candidates.append((p.distance(allCopper),i,k))
print('Destination region',len(reachable),flush=True)
for d,i,k in sorted(candidates,reverse=True)[:4]:
p=Point(xs[i],ys[k]);near=sorted((g.distance(p),l,oid) for l in layers for g,oid in zip(shapes[l],owners[l]))[:6]
print('Destination escape',float(xs[i]),float(ys[k]),d,'blockers',near,flush=True)
raise RuntimeError('Destination corridor has no legal via escape; reroute neighboring traces')
def heuristic(i,k,z):
dx=abs(i-ei);dy=abs(k-ej);return max(dx,dy)+.41421356*min(dx,dy)+(via_cost if z!=el else 0)
startkey=(si,sj,sl);dist={startkey:0};prev={};queue=[(heuristic(*startkey),0,startkey)];visited=0;beg=time.time();found=None
dirs=[(a,b,math.hypot(a,b)) for a in [-1,0,1] for b in [-1,0,1] if a or b]
while queue:
_,cost,key=heapq.heappop(queue)
if cost!=dist.get(key):continue
if key in goals:found=key;break
visited+=1
if visited%100000==0:print('visited',visited,'seconds',round(time.time()-beg,1),flush=True)
if time.time()-beg>120:raise RuntimeError('Local search timeout')
i,k,z=key;neighbors=[]
for di,dk,w in dirs:
ni,nk=i+di,k+dk
# Present conflicts cost more than historical congestion, so a now-clear
# corridor is preferable to displacing several additional signal routes.
if 0<=ni<nx and 0<=nk<ny and mask[z][nk,ni] and (not(di and dk) or (mask[z][k,ni] and mask[z][nk,i])):neighbors.append(((ni,nk,z),w+(soft_penalty if soft_masks[z][nk,ni] else 0)+int(history_cost[z][nk,ni])/4))
if viaMask[k,i]:
for nz in range(4):
if nz!=z and mask[nz][k,i]:neighbors.append(((i,k,nz),via_cost+(10*soft_penalty if softViaMask[k,i] else 0)))
for nxt,w in neighbors:
nc=cost+w
if nc<dist.get(nxt,math.inf):dist[nxt]=nc;prev[nxt]=key;heapq.heappush(queue,(nc+heuristic(*nxt),nc,nxt))
if found is None:raise RuntimeError('No route found')
path=[found]
while path[-1]!=startkey:path.append(prev[path[-1]])
path.reverse()
# Remove collinear grid points, then exact-check each segment independently.
simple=[path[0]]
for k in range(1,len(path)-1):
a,b,c=path[k-1:k+2]
if escape_width<width or (b[0]-a[0],b[1]-a[1],b[2]-a[2])!=(c[0]-b[0],c[1]-b[1],c[2]-b[2]):simple.append(b)
simple.append(path[-1]);route=[]
for k,(i,h,z) in enumerate(simple):
x=float(xs[i]);y=float(ys[h]);l=layers[z]
if (i,h) in viaOverrides and ((k and simple[k-1][2]!=z) or (k+1<len(simple) and simple[k+1][2]!=z)):x,y=viaOverrides[i,h][:2]
if k and simple[k-1][2]!=z:route.append({'route_type':'via','x':x,'y':y,'from_layer':'top','to_layer':'bottom','via_diameter':viaOverrides.get((i,h),(None,None,via_diameter,via_hole_diameter))[2],'via_hole_diameter':viaOverrides.get((i,h),(None,None,via_diameter,via_hole_diameter))[3]})
route.append({'route_type':'wire','x':x,'y':y,'width':width,'layer':l})
route[0]={**route[0],'x':sx,'y':sy};route.append({'route_type':'wire','x':ex,'y':ey,'width':width,'layer':end['layer']})
# Pull wire runs taut where exact copper clearance permits. This shortens
# decoupling paths without changing layers, endpoints, or widths.
if '--shorten' in sys.argv and escape_width == width:
pulled=[];i=0
while i<len(route):
a=route[i];pulled.append(a)
if a['route_type']!='wire':i+=1;continue
last=i
for k in range(i+1,len(route)):
b=route[k]
if b['route_type']!='wire' or b['layer']!=a['layer']:break
if LineString([(a['x'],a['y']),(b['x'],b['y'])]).distance(obstacles[layers.index(a['layer'])])>=clearance+width/2+1e-6:last=k
i=max(last,i+1)
route=pulled
for point in route:
if point['route_type']=='wire' and min(math.hypot(point['x']-sx,point['y']-sy),math.hypot(point['x']-ex,point['y']-ey))<escape_radius and escape_width<width:point['width']=escape_width
for a,b in zip(route,route[1:]):
if a['route_type']=='wire' and b['route_type']=='wire':
distance=LineString([(a['x'],a['y']),(b['x'],b['y'])]).distance(obstacles[layers.index(a['layer'])])
# The Gerber exporter uses the starting point's width for a segment. Begin
# a pad-escape neck one grid step early when the nominal-width segment would
# otherwise enter the narrow endpoint corridor. Exact clearance still gates it.
near_escape=min(math.hypot(p['x']-x,p['y']-y) for p in (a,b) for x,y in [(sx,sy),(ex,ey)]) < escape_radius+2*step
if distance<clearance+a['width']/2-1e-7 and escape_width<width and near_escape and distance>=clearance+escape_width/2-1e-7:
a['width']=escape_width
if distance<clearance+a['width']/2-1e-7:raise RuntimeError(f'Exact clearance failure: {distance}')
blockers=set()
def mark_congestion(x,y,layer,radius=.35):
hi=round((x+24)/.1);hj=round((y+24)/.1);rr=math.ceil(radius/.1);z=layers.index(layer)
for di in range(-rr,rr+1):
for dj in range(-rr,rr+1):
i,k=hi+di,hj+dj
if 0<=i<481 and 0<=k<481 and (di*.1)**2+(dj*.1)**2<=radius**2:history[z,k,i]=min(2000,int(history[z,k,i])+40)
if soft_routing:
for a,b,l,w in trace_segments(route):
segment=LineString([a,b])
for g,owner in zip(shapes[l],owners[l]):
# Search uses a clearance guard; only an actual design-rule conflict
# should displace an existing route. Re-ripping legal guarded gaps can
# otherwise make negotiation oscillate indefinitely.
if owner in pt and segment.distance(g)<.1+w/2-1e-7:
blockers.add(owner);hit=segment.intersection(g.buffer(.1+w/2));where=hit.centroid if not hit.is_empty else nearest_points(segment,g)[0];mark_congestion(where.x,where.y,l)
for v in route:
if v['route_type']!='via':continue
point=Point(v['x'],v['y'])
for l in layers:
for g,owner,kind in zip(shapes[l],owners[l],kinds[l]):
required=max(via_diameter/2+.1,via_hole_diameter/2+.2)
# For circular vias use centre distance, avoiding polygon-buffer chord
# error. Copper and drill-to-copper gaps are separate from drill spacing.
distance=point.distance(g.centroid)-(g.bounds[2]-g.bounds[0])/2 if kind=='via' else point.distance(g)
if owner in pt and distance<required-1e-7:blockers.add(owner);mark_congestion(v['x'],v['y'],l,.45)
np.savez_compressed(history_file,cost=history)
(report_dir/'local-route-blockers.json').write_text(json.dumps(sorted(blockers)))
print('Displaced routes',sorted(blockers),flush=True)
trace['route']=prefix+route
# Compact equal-width collinear wire points without changing copper geometry.
# Narrow escapes retain every width transition, and layer transitions stay intact.
for routed in plan['traces']:
compact=[]
for point in routed['route']:
compact.append(point)
while len(compact)>=3:
a,b,c=compact[-3:]
if not all(p['route_type']=='wire' for p in (a,b,c)):break
if len({p['layer'] for p in (a,b,c)})!=1 or max(p['width'] for p in (a,b,c))-min(p['width'] for p in (a,b,c))>1e-10:break
if set(b)-{'route_type','x','y','width','layer'}:break
dx,dy=b['x']-a['x'],b['y']-a['y'];ex,ey=c['x']-b['x'],c['y']-b['y']
if abs(dx*ey-dy*ex)>1e-10 or dx*ex+dy*ey<0:break
compact.pop(-2)
routed['route']=compact
(folder/'route-plan.json').write_text(json.dumps(plan,indent=2))
print('Routed',target,'width',width,'points',len(route),'vias',sum(p['route_type']=='via' for p in route),'visited',visited,'seconds',round(time.time()-beg,1),flush=True)
