AnasSarkiz/NEMA14_RP2040
A NEMA motor assembly concept with a controller board, four proposed M3 screws and spacers, motor lead splices, and a JST 4-pin connector harness.
- Version
- 1.13.0
- License
- ISC
- Stars
- 0
scripts/route-with-reviewed-ripup.py
"""Finish physically disconnected nets on exact supplier geometry.
The conservative routing grid is only a construction aid. Native DRC and the
independent physical/CAM checks are required afterward.
"""
import json, math, heapq, sys
import numpy as np
from PIL import Image,ImageDraw
from scipy.ndimage import distance_transform_edt
from shapely.ops import unary_union
import verify_supplier_connectivity as g
j=g.j;ROOT=g.ROOT;STEP=.025;LOW=-17.5;N=1401
construction_margin=float(next((q.split('=',1)[1] for q in sys.argv if q.startswith('--grid-margin-mm=')),.02));assert .003<=construction_margin<=.02
layers=['top','bottom'] if next(e for e in j if e['type']=='pcb_board')['num_layers']==2 else ['top','inner1','inner2','bottom']
nets={e['subcircuit_connectivity_map_key']:e for e in j if e['type']=='source_net'}
traces={e['subcircuit_connectivity_map_key']:e['source_trace_id'] for e in j if e['type']=='source_trace'}
for k,name in g.nets.items():
if k not in nets:nets[k]={'name':name,'source_net_id':None}
xy=lambda x,y:(round((x-LOW)/STEP),round((y-LOW)/STEP))
world=lambda q:(LOW+q[0]*STEP,LOW+q[1]*STEP)
SOFT_ELIGIBLE=set()
soft_crossing_cost=float(next((q.split('=',1)[1] for q in sys.argv if q.startswith('--rip-cost=')),30));assert 1<=soft_crossing_cost<=30
soft_wire=None
def soft_eligible(e):
name=g.nets.get(g.key(e),'')
uid=e.get('pcb_trace_id',e.get('pcb_via_id',''))
if e['type']=='pcb_via' and ('--rip-ordinary-vias' not in sys.argv or name in {'GND','PD_VBUS','DATA_VBUS','LOGIC_IN','V3V3','V1V1','VREG','VREF','VCP','BUCK_SW','BUCK_BST','A_PLUS','A_MINUS','B_PLUS','B_MINUS','SENSE1','SENSE2','BUZZER_SINK'}):return False
if name in {'USB_DP','USB_DM','MCU_DP','MCU_DM','XIN','XOUT','XTAL_OUT','QSPI_SCLK','QSPI_SD0','QSPI_SD1','QSPI_SD2','QSPI_SD3','QSPI_SS','RUN','SWCLK','SWDIO'}:return False
protected_nets={q.split('=',1)[1] for q in sys.argv if q.startswith('--protect-net=')}
if name in protected_nets:return False
if '--rip-pd-branches' in sys.argv and name=='PD_VBUS' and not uid.startswith(('usb_','reviewed_local_')):return True
if '--rip-power-branches' in sys.argv and name in {'V3V3','DATA_VBUS','LOGIC_IN'}:
if not uid.startswith(('usb_','oscillator_','review_escape_','reviewed_local_')):return True
if '--rip-ground-branches' in sys.argv and name=='GND' and not uid.startswith(('usb_','oscillator_','reviewed_local_','feedback_ground_escape_')):return True
if '--protect-signals' in sys.argv:return False
return name not in {'GND','PD_VBUS','DATA_VBUS','LOGIC_IN','V3V3','V1V1','VREG','VREF','VCP','BUCK_SW','BUCK_BST','A_PLUS','A_MINUS','B_PLUS','B_MINUS','SENSE1','SENSE2','BUZZER_SINK'} and not uid.startswith(('usb_','review_escape_','reviewed_local_'))
def width_for_layer(layer):
if '--adaptive-power-layers' in sys.argv:
name=nets[net]['name']
if name=='PD_VBUS':return .55 if layer.startswith('inner') else .2
if name=='DATA_VBUS':return .3 if layer.startswith('inner') else .2
return current_width
def blocked(net,via=False):
global soft_wire
soft_images=[Image.new('L',(N,N)) for _ in layers];soft_ds=[ImageDraw.Draw(im) for im in soft_images]
draw_layers=['top','inner1','inner2','bottom'] if via and next(e for e in j if e['type']=='pcb_board')['num_layers']==4 else layers
images=[Image.new('L',(N,N)) for _ in draw_layers];ds=[ImageDraw.Draw(im) for im in images]
margin=max(.15+current_via_diameter/2+construction_margin,.20+current_via_hole/2+construction_margin) if via else .15+current_width/2+construction_margin
def add(ls,geom,extra=0,soft=False):
for l in ls:
if l not in draw_layers:continue
layer_margin=margin if via else .15+width_for_layer(l)/2+construction_margin
p=geom.buffer(layer_margin+extra,quad_segs=16)
pieces=list(p.geoms) if hasattr(p,'geoms') else [p]
d=(soft_ds if soft else ds)[draw_layers.index(l)]
for piece in pieces:
if piece.geom_type=='Polygon' and not piece.is_empty:d.polygon([xy(x,y) for x,y in piece.exterior.coords],fill=1)
for e in j:
typ=e['type']
if typ in ['pcb_smtpad','pcb_plated_hole']:
if g.key(e)==net and not via:continue
add(e.get('layers',[e.get('layer','top')]),g.geometry(e),-.135 if g.key(e)==net and via else .1 if typ=='pcb_plated_hole' else 0)
elif typ=='pcb_via':
if g.key(e)==net and not via:continue
eligible=soft_eligible(e)
if via and eligible:continue
add(draw_layers,g.Point(e['x'],e['y']).buffer(e['outer_diameter']/2),soft=not via and eligible)
elif typ=='pcb_trace' and g.key(e)!=net:
if via and '--rip-ordinary-vias' in sys.argv and soft_eligible(e) and g.nets.get(g.key(e)) not in {'GND','PD_VBUS','DATA_VBUS','LOGIC_IN','V3V3','V1V1','VREG','VREF','VCP','BUCK_SW','BUCK_BST','A_PLUS','A_MINUS','B_PLUS','B_MINUS','SENSE1','SENSE2','BUZZER_SINK'}:continue
for a,b in zip(e['route'],e['route'][1:]):
if a['route_type']==b['route_type']=='wire' and a['layer']==b['layer']:
add([a['layer']],g.LineString([(a['x'],a['y']),(b['x'],b['y'])]).buffer(max(a['width'],b['width'])/2),soft=not via and soft_eligible(e))
elif typ=='pcb_keepout':
# Exact circular keepout; native bounding-box diagnostics are independently reconciled.
c=e['center'];r=e['radius'];add(e['layers'],g.Point(c['x'],c['y']).buffer(r,quad_segs=128))
elif typ=='pcb_hole':add(draw_layers,g.Point(e['x'],e['y']).buffer(e['hole_diameter']/2+.1))
policy_path=ROOT/'artifacts/engineering-fixes/usb-routing-plan.json'
if policy_path.exists():
policy=json.loads(policy_path.read_text())
endpoints=[g.Point(c['centerline'][i]) for c in policy['cores'] for i in [0,-1]]
endpoint_regions=unary_union([p.buffer(1.25) for p in endpoints])
for core in policy['cores']:
guard=g.LineString(core['centerline']).buffer(json.loads((ROOT/'src/layer-routing-policy.json').read_text()).get('USB_construction_reference_buffer_mm',policy['reference_corridor_buffer_mm']))
if via and nets[net]['name']!='GND':
# All new foreign-net barrels stay outside the trunk reference corridor;
# paired USB terminal transitions are confined to local fanouts.
guard=guard
add(draw_layers,guard)
elif nets[net]['name']!='GND':add([core['reference_layer']],guard)
oscillator_path=ROOT/'artifacts/upright-revision/oscillator-plan.json'
if oscillator_path.exists() and nets[net]['name'] not in ['GND','XIN','XOUT','XTAL_OUT']:
op=json.loads(oscillator_path.read_text())
region=unary_union([g.LineString(r['points']).buffer(op['guard_buffer_mm']) for r in op['routes']])
exempt=unary_union([g.Point(r['points'][0]).buffer(op['MCU_pin_local_exemption_radius_mm']) for r in op['routes'] if r['trace'] in ['oscillator_XIN','oscillator_XOUT']])
add(draw_layers if via else op['layers_reserved'],region.difference(exempt))
for l,d in zip(draw_layers,ds):
b=math.ceil((.35+(current_via_diameter/2 if via else width_for_layer(l)/2))/STEP)
d.rectangle([0,0,N-1,b],fill=1);d.rectangle([0,N-1-b,N-1,N-1],fill=1)
d.rectangle([0,0,b,N-1],fill=1);d.rectangle([N-1-b,0,N-1,N-1],fill=1)
masks=[np.asarray(im,dtype=np.bool_) for im in images]
if not via:soft_wire=[np.asarray(im,dtype=np.bool_) for im in soft_images]
return masks
def accessible_pad_points(pad):
shape=g.geometry(pad);a,b,c,d=shape.bounds
points={(round(x/STEP)*STEP,round(y/STEP)*STEP) for x in np.arange(a+.025,c,.05) for y in np.arange(b+.025,d,.05)}
p=g.ports[pad['pcb_port_id']];points.add((p['x'],p['y']))
return [(x,y) for x,y in points if shape.buffer(-.002).covers(g.Point(x,y))]
repairs=[]
# Replace traces that physically clear the circular keepout but fail the
# native bounding-square trace checker. Do not suppress their DRC errors.
errors=json.loads((ROOT/'artifacts/drc-report.json').read_text())['errors']
for error in errors:
if 'pcb_keepout' in error.get('message',''):
e=next((e for e in j if e.get('pcb_trace_id')==error['pcb_trace_id']),None)
if e is None:continue
j.remove(e)
repairs.append({'removed_for_conservative_native_keepout':error['pcb_trace_id']})
for net in sorted(nets,key=lambda k:({'QSPI_SCLK':-4,'QSPI_SD0':-4,'QSPI_SD1':-4,'QSPI_SD2':-4,'QSPI_SD3':-4,'XIN':-4,'XOUT':-4,'XTAL_OUT':-4,'SWCLK':-4,'SWDIO':-4,'RUN':-4,'USB_DP':-20,'USB_DM':-20,'MCU_DP':-20,'MCU_DM':-20,'BUCK_BST':-3,'TEMP_SDA':-2,'TEMP_SCL':-2,'BUZZER_PWM':-2,'LED_R':-2,'LED_G':-2,'LED_B':-2,'PWR_LED_A':-5,'SLEEP':4,'ENABLE_N':3,'VREG':0,'VREF':0,'VCP':0,'SENSE2':0,'A_PLUS':0,'B_PLUS':0,'B_MINUS':0,'PD_GOOD':1,'VM_SENSE':2}.get(nets[k]['name'],3))):
if '--ground' in sys.argv and nets[net]['name']!='GND':continue
requested=next((q.split('=',1)[1] for q in sys.argv if q.startswith('--net=')),None)
if requested and nets[net]['name']!=requested:continue
if nets[net]['name']=='GND' and '--ground' not in sys.argv:continue # Regenerate plane returns before any separate ground repair.
current_width=.3 if nets[net]['name'] in ['A_PLUS','A_MINUS','B_PLUS','B_MINUS','SENSE1','SENSE2','V3V3','LOGIC_IN','DATA_VBUS','BUCK_SW','BUZZER_SINK'] else .16
if nets[net]['name']=='PD_VBUS':current_width=.55
power=nets[net]['name'] in ['PD_VBUS','A_PLUS','A_MINUS','B_PLUS','B_MINUS','SENSE1','SENSE2','V3V3','LOGIC_IN','DATA_VBUS','BUCK_SW','BUZZER_SINK','GND']
if nets[net]['name'] in ['USB_DP','USB_DM','MCU_DP','MCU_DM']:layers=['top','bottom']
elif nets[net]['name'] in ['PD_VBUS','A_PLUS','B_PLUS','B_MINUS','SENSE1','SENSE2','LOGIC_IN','BUCK_SW','BUCK_BST','CP1','CP2','VCP','VREG','VREF']:layers=['top','inner2','bottom']
elif nets[net]['name'] in ['QSPI_SCLK','QSPI_SD0','QSPI_SD1','QSPI_SD2','QSPI_SD3','QSPI_SS','XIN','XOUT','XTAL_OUT','RUN','SWCLK','SWDIO','USB_DP','USB_DM','MCU_DP','MCU_DM','DIR','STATUS_B_K','LED_B','LED_G','PWR_LED_A','STEP','V3V3','DATA_VBUS','TEMP_SDA','TEMP_SCL','V1V1','GND','DATA_CC1','DATA_CC2','VM_SENSE','ENABLE_N','BOOT_PAD','STATUS_R_C','STATUS_G_C','STATUS_B_C','DCK','DIO']:layers=['top','inner1','inner2','bottom']
elif nets[net]['name']=='A_MINUS':layers=['top','inner1','inner2','bottom']
else:layers=['top','inner2','bottom']
board=next(e for e in j if e['type']=='pcb_board')
current_via_diameter=max(board['min_via_pad_diameter'],.5 if nets[net]['name'] in ['PD_VBUS','A_PLUS','A_MINUS','B_PLUS','B_MINUS','SENSE1','SENSE2','BUZZER_SINK'] else .4)
current_via_hole=max(board['min_via_hole_diameter'],.25 if nets[net]['name'] in ['PD_VBUS','A_PLUS','A_MINUS','B_PLUS','B_MINUS','SENSE1','SENSE2','BUZZER_SINK'] else .2)
override=next((float(q.split('=',1)[1]) for q in sys.argv if q.startswith('--via-pad-mm=')),None)
if override is not None:
assert board['min_via_pad_diameter']<=override<=.6 and override-current_via_hole>=.15-1e-9
current_via_diameter=override
for attempt in range(40):
elems,roots=g.groups(net);islands={}
for e,r in zip(elems,roots):
if e.get('pcb_port_id'):islands.setdefault(r,[]).append(e)
core_roots={r for e,r in zip(elems,roots) if e['type']=='pcb_trace' and e['pcb_trace_id'].startswith('usb_coupled_')}
usb_net=nets[net]['name'] in ['USB_DP','USB_DM','MCU_DP','MCU_DM']
if len(set(islands)|core_roots)<2:break
if usb_net and core_roots:
core_geometry=unary_union([g.LineString([(q['x'],q['y']) for q in e['route']]) for e in elems if e['type']=='pcb_trace' and e['pcb_trace_id'].startswith('usb_coupled_')])
candidates=[r for r in islands if r not in core_roots] or list(islands)
startroot=min(candidates,key=lambda r:min(g.geometry(p).distance(core_geometry) for p in islands[r]))
else:startroot=min(islands,key=lambda r:(len(islands[r]),not any(e['type']=='pcb_plated_hole' for e in islands[r])))
requested_start=next((q.split('=',1)[1] for q in sys.argv if q.startswith('--start-ref=')),None)
if requested_start:
candidates=[r for r,ps in islands.items() if any(g.src[g.sp[g.ports[p['pcb_port_id']]['source_port_id']]['source_component_id']]==requested_start for p in ps)]
if candidates:startroot=min(candidates,key=lambda r:len(islands[r]))
if nets[net]['name']=='PD_VBUS':
current_width=.55
if len(islands[startroot])==1:
isolated=islands[startroot][0];port=g.ports[isolated['pcb_port_id']];sp=g.sp[port['source_port_id']];reference=g.src[sp['source_component_id']]
if (reference=='U_DRV' and sp['name'] in ['VBB1','VBB2']) or (reference=='C_VCP' and sp['name']=='pin2') or reference in ['R_PD','C_VM']:current_width=.3
if reference=='R_VM_H':current_width=.16 # Only the verified high-value voltage-divider terminal.
if nets[net]['name']=='PD_VBUS':
refs={g.src[g.sp[g.ports[p['pcb_port_id']]['source_port_id']]['source_component_id']] for p in islands[startroot]}
layers=['top','inner2','bottom']
if refs=={'J_PD'}:
current_width=.3
layers=['top','bottom'] # Full 0.75 A screened on external copper.
if nets[net]['name']=='DATA_VBUS':
refs={g.src[g.sp[g.ports[p['pcb_port_id']]['source_port_id']]['source_component_id']] for p in islands[startroot]}
if refs=={'R_USB_SENSE_H'}:
assert next(e for e in j if e['type']=='source_component' and e['name']=='R_USB_SENSE_H')['resistance']==100000
current_width=.16 # Only the physically isolated 100k divider-input terminal; independent branch-cut thermal audit remains mandatory.
if nets[net]['name']=='V3V3':
refs={g.src[g.sp[g.ports[p['pcb_port_id']]['source_port_id']]['source_component_id']] for p in islands[startroot]}
load=sum({'U_MCU':.15,'U_FLASH':.03,'U_DRV':.02,'U_TEMP':.001,'BZ1':.12,'R_PWR_LED':.011,'R_TEMP_SDA':.001,'R_TEMP_SCL':.001}.get(ref,0) for ref in refs)
current_width=.16 if 'L_BUCK' not in refs and load<=.18 else .3
# The independent physical branch-cut and thermal audit verifies
# downstream load again on the completed copper, with full-budget fallback.
if nets[net]['name']=='V3V3':current_width=max(current_width,.2)
phase_bridge=nets[net]['name'] in ['A_PLUS','A_MINUS','B_PLUS','B_MINUS'] and '--phase-bridges' in sys.argv
if phase_bridge:layers=['top','inner1','inner2','bottom']
requested_layers=next((q.split('=',1)[1].split(',') for q in sys.argv if q.startswith('--layers=')),None)
if requested_layers:
assert set(requested_layers)<={'top','inner1','inner2','bottom'} and {'top','bottom'}<=set(requested_layers)
assert nets[net]['name'] not in ['USB_DP','USB_DM','MCU_DP','MCU_DM']
layers=requested_layers
if ('--outer-pd-feeder' in sys.argv and nets[net]['name']=='PD_VBUS') or ('--outer-data-feeder' in sys.argv and nets[net]['name']=='DATA_VBUS'):
assert set(layers)=={'top','bottom'},'Full-budget 0.30 mm PD feeder requires the 35 um outer layers; thermal audit remains mandatory'
outer_width=float(next((q.split('=',1)[1] for q in sys.argv if q.startswith('--outer-feeder-width=')),.3))
assert .16<=outer_width<=.3
current_width=outer_width
goal_ref=next((q.split('=',1)[1] for q in sys.argv if q.startswith('--goal-ref=')),None)
target_roots={r for r,ps in islands.items() if any(g.src[g.sp[g.ports[p['pcb_port_id']]['source_port_id']]['source_component_id']]==goal_ref for p in ps)} if goal_ref else set()
if goal_ref and startroot in target_roots:
print('Requested physical terminal groups joined',nets[net]['name'],requested_start,goal_ref,flush=True);break
# Use a real connected physical land as the start, never an old location.
startpad=min(islands[startroot],key=lambda e:len(e.get('port_hints',[])))
startport=g.ports[startpad['pcb_port_id']]
traces[net]=next(t['source_trace_id'] for t in j if t['type']=='source_trace' and t.get('subcircuit_connectivity_map_key')==net and startport['source_port_id'] in t.get('connected_source_port_ids',[]))
sx,sy=startport['x'],startport['y'];startlayer=startpad.get('layer','top')
start=(*xy(sx,sy),layers.index(startlayer));goals={}
def goal(x,y,l):
if l in layers:goals[(*xy(x,y),layers.index(l))]=(x,y)
for e,r in zip(elems,roots):
if r==startroot or r not in (set(islands)|core_roots):continue
if goal_ref and r not in target_roots:continue
if usb_net and core_roots and r not in core_roots:continue
if e['type'] in ['pcb_smtpad','pcb_plated_hole'] and e.get('pcb_port_id'):
p=g.ports[e['pcb_port_id']]
for l in e.get('layers',[e.get('layer','top')]):
for x,y in accessible_pad_points(e):goal(x,y,l)
elif e['type']=='pcb_via':
for l in e['layers']:goal(e['x'],e['y'],l)
elif e['type']=='pcb_copper_pour' and nets[net]['name']=='GND':
from shapely.geometry import Polygon
b=e['brep_shape'];pts=lambda r:[(p['x'],p['y']) for p in r['vertices']]
poly=Polygon(pts(b['outer_ring']),[pts(r) for r in b.get('inner_rings',[])]).buffer(-.01)
for piece in list(poly.geoms) if hasattr(poly,'geoms') else [poly]:
if piece.is_empty or piece.geom_type!='Polygon':continue
rp=piece.representative_point();goal(rp.x,rp.y,e['layer'])
for ring in [piece.exterior]+list(piece.interiors):
for i in range(max(1,math.ceil(ring.length/.15))):
q=ring.interpolate(i*.15);goal(q.x,q.y,e['layer'])
elif e['type']=='pcb_trace':
if e['pcb_trace_id'].startswith('usb_coupled_'):
policy=json.loads((ROOT/'artifacts/engineering-fixes/usb-routing-plan.json').read_text())
track=next(t for c in policy['cores'] for t in c['tracks'] if t['trace_id']==e['pcb_trace_id'])
for x,y in [track['points'][0],track['points'][-1]]:goal(x,y,e['route'][0]['layer'])
continue
for a,b in zip(e['route'],e['route'][1:]):
if a['route_type']==b['route_type']=='wire' and a['layer']==b['layer']:
count=max(1,math.ceil(math.hypot(b['x']-a['x'],b['y']-a['y'])/.15))
for k in range(count+1):goal(a['x']+(b['x']-a['x'])*k/count,a['y']+(b['y']-a['y'])*k/count,a['layer'])
if not goals:
print("UNROUTED: no other physical copper",nets[net]["name"],flush=True);break
wire=blocked(net);vias=blocked(net,True)
preferred_layer=next((q.split('=',1)[1] for q in sys.argv if q.startswith('--preferred-layer=')),None)
if preferred_layer:
assert preferred_layer in layers
wire=[m if l==preferred_layer else np.ones_like(m) for l,m in zip(layers,wire)]
existing={xy(e["x"],e["y"]):e for e in j if e["type"]=="pcb_via" and g.key(e)==net}
goals={q:v for q,v in goals.items() if not wire[q[2]][q[1],q[0]]}
if not goals:
print("UNROUTED: no accessible target copper",nets[net]["name"],flush=True);break
points=np.array(list(goals));lo=points[:,:2].min(axis=0);hi=points[:,:2].max(axis=0)
goal_mask=np.ones((N,N),dtype=np.bool_)
for q in goals:goal_mask[q[1],q[0]]=False
distance_field=distance_transform_edt(goal_mask)
search_weight=float(next((q.split('=',1)[1] for q in sys.argv if q.startswith('--search-weight=')),1.5))
assert 1<=search_weight<=5,'Weighted search affects construction only; all clearance checks remain mandatory'
search_limit=int(next((q.split('=',1)[1] for q in sys.argv if q.startswith('--max-expanded=')),5000000))
assert 1000000<=search_limit<=20000000,'Search budget affects construction only'
heuristic=lambda s:search_weight*float(distance_field[s[1],s[0]])
starts={(*xy(x,y),layers.index(l)):(x,y) for l in startpad.get('layers',[startpad.get('layer','top')]) if l in layers for x,y in accessible_pad_points(startpad)}
for anchor,r in zip(elems,roots):
if r!=startroot:continue
if anchor['type']=='pcb_via':
for l in anchor['layers']:
if l in layers:starts[(*xy(anchor['x'],anchor['y']),layers.index(l))]=(anchor['x'],anchor['y'])
elif anchor['type']=='pcb_trace':
for a,b in zip(anchor['route'],anchor['route'][1:]):
if a['route_type']==b['route_type']=='wire' and a['layer']==b['layer'] and a['layer'] in layers:
count=max(1,math.ceil(math.hypot(b['x']-a['x'],b['y']-a['y'])/.15))
for k in range(count+1):
x=a['x']+(b['x']-a['x'])*k/count;y=a['y']+(b['y']-a['y'])*k/count
starts[(*xy(x,y),layers.index(a['layer']))]=(x,y)
starts={q:v for q,v in starts.items() if not wire[q[2]][q[1],q[0]]}
if '--fast-grid' in sys.argv:
from fast_grid_router import route as fast_route
path,expanded=fast_route(wire,soft_wire,vias,existing,distance_field,starts,goals,search_weight,soft_crossing_cost,phase_bridge,layers,limit=search_limit,inner_cost=float(next((q.split("=",1)[1] for q in sys.argv if q.startswith("--inner1-cost=")),1)))
if path is None:
print('UNROUTED: no legal grid route',nets[net]['name'],expanded,flush=True);break
end=path[-1]
slim=[path[0]]
else:
heap=[(heuristic(q),0,q) for q in starts];heapq.heapify(heap);best={q:0 for q in starts};prev={};end=None;expanded=0
while heap:
_,cost,s=heapq.heappop(heap)
if cost!=best.get(s):continue
if s in goals:end=s;break
expanded+=1
if expanded>search_limit:break
x,y,l=s
options=[(x+dx,y+dy,l,math.sqrt(2) if dx and dy else 1) for dx,dy in [(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)]]
if (x,y) in existing or all(not b[y,x] for b in vias):options.extend((x,y,q,45) for q in range(len(layers)) if q!=l)
for nx,ny,nl,dc in options:
if not(0<=nx<N and 0<=ny<N) or wire[nl][ny,nx]:continue
if nl==l and nx!=x and ny!=y and (wire[l][y,nx] or wire[l][ny,x]):continue
q=(nx,ny,nl);nc=cost+dc*(12 if phase_bridge and nl==l and layers[nl]=='inner1' else 1)+(soft_crossing_cost if soft_wire[nl][ny,nx] else 0)
if nc<best.get(q,float('inf')):best[q]=nc;prev[q]=s;heapq.heappush(heap,(nc+heuristic(q),nc,q))
if end is None:
print("UNROUTED: no legal grid route",nets[net]["name"],expanded,flush=True);break
path=[end]
while path[-1] not in starts:path.append(prev[path[-1]])
path.reverse();slim=[path[0]]
for k in range(1,len(path)-1):
a,b,c=path[k-1:k+2]
if tuple(b[q]-a[q] for q in range(3))!=tuple(c[q]-b[q] for q in range(3)):slim.append(b)
slim.append(path[-1]);route=[];newvia=[]
uid=f'same_side_repair_{len(repairs)}_{nets[net]["name"]}_{attempt}'
used_ids={e.get("pcb_trace_id") for e in j if e["type"]=="pcb_trace"}
while uid in used_ids or any(e.get("pcb_via_id", "").startswith(uid+"_via_") for e in j):uid+="_rerouted"
for k,node in enumerate(slim):
x,y=world(node);l=layers[node[2]]
if (node[0],node[1]) in existing:x,y=existing[(node[0],node[1])]["x"],existing[(node[0],node[1])]["y"]
if k and node[2]!=slim[k-1][2]:
route.append({'route_type':'via','x':x,'y':y,'from_layer':layers[slim[k-1][2]],'to_layer':l,'via_diameter':current_via_diameter,'via_hole_diameter':current_via_hole})
all_layers=['top','inner1','inner2','bottom'] if board['num_layers']==4 else ['top','bottom']
v={'type':'pcb_via','pcb_via_id':uid+f'_via_{k}','x':x,'y':y,'outer_diameter':current_via_diameter,'hole_diameter':current_via_hole,'layers':all_layers,'from_layer':'top','to_layer':'bottom','source_net_id':nets[net]['source_net_id'],'source_trace_id':traces[net],'subcircuit_connectivity_map_key':net,'subcircuit_id':'subcircuit_source_group_0','tented_on_top':True,'tented_on_bottom':True}
oldvia=next((e for e in j if e['type']=='pcb_via' and math.hypot(e['x']-x,e['y']-y)<.001),None)
if oldvia:
assert g.key(oldvia)==net,'A route cannot reuse a via owned by another net'
assert oldvia['from_layer']=='top' and oldvia['to_layer']=='bottom'
oldvia['layers']=all_layers
else:j.append(v);newvia.append([x,y])
route.append({'route_type':'wire','x':x,'y':y,'layer':l,'width':width_for_layer(l)})
sx,sy=starts[path[0]];route[0].update(x=sx,y=sy)
if route[0]['layer'] in startpad.get('layers',[startpad.get('layer','top')]) and g.geometry(startpad).covers(g.Point(sx,sy)):route[0]['start_pcb_port_id']=startpad['pcb_port_id']
gx,gy=goals[end];route[-1].update(x=gx,y=gy)
for e in j:
if e['type'] in ['pcb_smtpad','pcb_plated_hole'] and g.key(e)==net and e.get('pcb_port_id') and route[-1]['layer'] in e.get('layers',[e.get('layer','top')]) and g.geometry(e).buffer(.001).covers(g.Point(gx,gy)):
route[-1]['end_pcb_port_id']=e['pcb_port_id'];break
if phase_bridge:
def inner_length(r):
return sum(math.dist((a['x'],a['y']),(b['x'],b['y'])) for a,b in zip(r,r[1:]) if a['route_type']==b['route_type']=='wire' and a['layer']==b['layer']=='inner1')
existing_inner=sum(inner_length(t['route']) for t in j if t['type']=='pcb_trace' and g.key(t)==net)
proposed_total=existing_inner+inner_length(route)
limit=json.loads((ROOT/'src/layer-routing-policy.json').read_text())['maximum_total_phase_inner1_length_mm']
if proposed_total>limit+1e-6:
# Save the rejected construction candidate for geometry analysis;
# the saved board and the qualification rule remain unchanged.
candidate_trace={'type':'pcb_trace','pcb_trace_id':uid,'route':route,'source_net_id':nets[net]['source_net_id'],'source_trace_id':traces[net],'subcircuit_connectivity_map_key':net,'subcircuit_id':'subcircuit_source_group_0','pcb_port_ids':[p[k] for p in [route[0],route[-1]] for k in ['start_pcb_port_id','end_pcb_port_id'] if k in p]}
candidate_vias=[e for e in j if e['type']=='pcb_via' and e['pcb_via_id'].startswith(uid+'_via_')]
(ROOT/('artifacts/opposite-edge/rejected-phase-'+nets[net]['name']+'.json')).write_text(json.dumps({'trace':candidate_trace,'vias':candidate_vias,'existing_inner1_mm':existing_inner,'proposed_total_inner1_mm':proposed_total,'limit_mm':limit},indent=2)+'\n')
j[:]=[e for e in j if not (e['type']=='pcb_via' and e['pcb_via_id'].startswith(uid+'_via_'))]
print('UNROUTED: proposed phase transitions exceed total inner1 length budget',nets[net]['name'],proposed_total,limit,flush=True)
break
ripped=set()
# Index foreign wire segments once; keep the same exact clearance and
# protected-copper assertions while avoiding repeated geometry builds.
from shapely.strtree import STRtree
foreign_shapes=[];foreign_owners=[];foreign_layers=[]
for foreign in j:
if g.key(foreign)==net:continue
if foreign['type']=='pcb_via':
for lname in ['top','inner1','inner2','bottom']:
foreign_shapes.append(g.Point(foreign['x'],foreign['y']).buffer(foreign['outer_diameter']/2));foreign_owners.append(foreign);foreign_layers.append(lname)
continue
if foreign['type']!='pcb_trace':continue
for c,d in zip(foreign['route'],foreign['route'][1:]):
if c['route_type']==d['route_type']=='wire' and c['layer']==d['layer']:
foreign_shapes.append(g.LineString([(c['x'],c['y']),(d['x'],d['y'])]).buffer(max(c['width'],d['width'])/2))
foreign_owners.append(foreign);foreign_layers.append(c['layer'])
foreign_index=STRtree(foreign_shapes)
construction_shapes=[]
for vnew in j:
if vnew['type']=='pcb_via' and vnew['pcb_via_id'].startswith(uid+'_via_'):
for lname in ['top','inner1','inner2','bottom']:construction_shapes.append((lname,g.Point(vnew['x'],vnew['y']).buffer(vnew['outer_diameter']/2)))
for a,b in zip(route,route[1:]):
if a['route_type']!=b['route_type'] or a['route_type']!='wire' or a['layer']!=b['layer']:continue
poly=g.LineString([(a['x'],a['y']),(b['x'],b['y'])]).buffer(max(a['width'],b['width'])/2)
construction_shapes.append((a['layer'],poly))
for lname,poly in construction_shapes:
for index in foreign_index.query(poly.buffer(.15)):
if foreign_layers[index]!=lname:continue
distance=poly.distance(foreign_shapes[index])
if distance<.15-.000001:
foreign=foreign_owners[index];fid=foreign[foreign['type']+'_id']
assert soft_eligible(foreign),('Route crosses protected copper',nets[net]['name'],fid,g.nets.get(g.key(foreign)),distance)
ripped.add(fid)
rip_limit=int(next((q.split('=',1)[1] for q in sys.argv if q.startswith('--max-ripped-traces=')),4));assert 0<=rip_limit<=16,'Construction rip-up budget must be between zero and sixteen traces; every displaced net requires full requalification';assert len(ripped)<=rip_limit,('Controlled rip-up budget exceeded',sorted(ripped))
print('RIP_FOR',nets[net]['name'],sorted(ripped),flush=True)
ripped_vias={e['pcb_via_id'] for e in j if e['type']=='pcb_via' and e['pcb_via_id'] in ripped}
j[:]=[e for e in j if e.get(e['type']+'_id') not in ripped]
j.append({'type':'pcb_trace','pcb_trace_id':uid,'route':route,'source_net_id':nets[net]['source_net_id'],'source_trace_id':traces[net],'subcircuit_connectivity_map_key':net,'subcircuit_id':'subcircuit_source_group_0','pcb_port_ids':[p[k] for p in [route[0],route[-1]] for k in ['start_pcb_port_id','end_pcb_port_id'] if k in p]})
repairs.append({'net':nets[net]['name'],'start_port':startpad['pcb_port_id'],'start_mm':[sx,sy],'end_mm':[gx,gy],'vias_mm':newvia,'expanded':expanded,'trace_id':uid,'removed_foreign_trace_ids':sorted(ripped-ripped_vias),'removed_foreign_via_ids':sorted(ripped_vias)})
print(repairs[-1],flush=True)
for obj in j:
if obj.get('source_net_id',1) is None:obj.pop('source_net_id')
g.path.write_text(json.dumps(j,indent=2)+'\n')
p=ROOT/'artifacts/same-side-copper-repairs.json';history=json.loads(p.read_text()) if p.exists() else [];history.append(repairs[-1]);p.write_text(json.dumps(history,indent=2)+'\n')
else:raise RuntimeError('Repair iteration limit')
g.path.write_text(json.dumps(j,indent=2)+'\n')
p=ROOT/'artifacts/same-side-copper-repairs.json';history=json.loads(p.read_text()) if p.exists() else []
history.extend([r for r in repairs if 'removed_for_conservative_native_keepout' in r]);p.write_text(json.dumps(history,indent=2)+'\n')