techmannih/museview

A 50×35 mm USB‑C 5 V camera board combining an ESP32‑S3 module, M0031 parallel camera connector, buck/LDO power rails, USB/ESD protection, controls, status LED, and passives.

Version
0.1.18
License
unset
Stars
1

scripts/route-grid.py

import json, math, heapq, time, sys, argparse
from pathlib import Path
import numpy as np
R=Path(__file__).resolve().parents[1]
parser=argparse.ArgumentParser(description="Board-specific clearance-aware MuseView router")
parser.add_argument("--input",type=Path,default=R/"routing/input.simple-route.json")
parser.add_argument("--geometry",type=Path,default=R/"routing/geometry.json")
parser.add_argument("--output",type=Path,required=True)
args=parser.parse_args()
I=json.load(open(args.input)); C=json.load(open(args.geometry))
VIA_HOLE=float(I['minViaHoleDiameter'])
# The board's minimum pad is a floor, not the requested actual via diameter.
# Retain 0.15 mm annular copper around the 0.30 mm through drill.
VIA_PAD=max(float(I['minViaPadDiameter']),VIA_HOLE+.3); VIA_RADIUS=VIA_PAD/2
if VIA_PAD-VIA_HOLE < .3-1e-9: raise ValueError('Vias require at least 0.15 mm annular ring')
STEP=.05; X0=-25.;Y0=-17.5;NX=1001;NY=701;N=NX*NY
xs=X0+np.arange(NX)*STEP;ys=Y0+np.arange(NY)*STEP
parent={}
def find(a):
 parent.setdefault(a,a)
 if parent[a]!=a:parent[a]=find(parent[a])
 return parent[a]
def union(a,b):parent[find(b)]=find(a)
def join(ids):
 for p in ids[1:]:union(ids[0],p)
for o in I['obstacles']:join(o.get('connectedTo',[]))
for c in I['connections']:join([c['name']]+[p['pointId'] for p in c['pointsToConnect']])
for t in I['traces']:join([t['connection_name']]+t.get('connectsTo',[]))
canonical={}
for s in C:
 if s['type']=='source_net':canonical[find(s['source_net_id'])]=s['source_net_id']
def net(ids):
 for k in ids:
  r=find(k)
  if r in canonical:return canonical[r]
 return find(ids[0]) if ids else '__keepout'
ports={p['pcb_port_id']:p for p in C if p['type']=='pcb_port'}
names={p['source_net_id']:p['name'] for p in C if p['type']=='source_net'}
points={};aliases={}
for c in I['connections']:
 n=net([c['name']]);points.setdefault(n,{})
 if c.get('source_trace_id'):aliases[n]=c['source_trace_id']
 for p in c['pointsToConnect']:points[n][p['pointId']]=p
fixed=list(I['traces'])
fixed.append(next(t for t in C if t['type']=='pcb_trace' and t['pcb_trace_id']=='pcb_trace_8'))
# Geometries: net, layers, primitive, parameters; all coordinates in board mm.
geoms=[];padgeoms=[];viaxy=[]
for o in I['obstacles']:
 if o.get('isCopperPour'):continue
 layers=[0 if l=='top' else 1 for l in o['layers'] if l in ['top','bottom']]
 if not layers:continue
 n=net(o.get('connectedTo',[]));x=o['center']['x'];y=o['center']['y'];w=o['width'];h=o['height'];a=o.get('ccwRotationDegrees',0)
 if o.get('circuitJsonMetadata',{}).get('pcb_via_id'):
  viaxy.append((x,y,n));g=(n,layers,'circle',(x,y,max(w,h)/2),.13)
 elif o.get('shape')=='circle':g=(n,layers,'circle',(x,y,max(w,h)/2),.23 if o.get('isNonPlatedHole') else .13)
 else:g=(n,layers,'rect',(x,y,w,h,a),.13)
 geoms.append(g)
 if o.get('circuitJsonMetadata',{}).get('pcb_smtpad_id') or o.get('circuitJsonMetadata',{}).get('pcb_plated_hole_id'):padgeoms.append(g)
# Ground exposed-pad island is actual top copper, represented in the input.
def add_trace_geometry(t,n):
 rr=t['route']
 for a,b in zip(rr,rr[1:]):
  if a['route_type']=='wire' and b['route_type']=='wire' and a.get('layer')==b.get('layer') and a.get('layer') in ['top','bottom']:
   geoms.append((n,[0 if a['layer']=='top' else 1],'seg',(a['x'],a['y'],b['x'],b['y'],max(a['width'],b['width'])/2),.13))
 for p in rr:
  if p['route_type']=='via':
   geoms.append((n,[0,1],'circle',(p['x'],p['y'],VIA_RADIUS),.13));viaxy.append((p['x'],p['y'],n))
for t in fixed:
 n=net([t.get('connection_name',t.get('source_trace_id',''))]+t.get('connectsTo',[]));add_trace_geometry(t,n)
# Explicit vias have both top and bottom ports; expose both as routing terminals.
manualvias=[v for v in C if v['type']=='pcb_via' and int(v['pcb_via_id'].split('_')[-1])<39]
for v in manualvias:
 if not any(math.hypot(v['x']-x,v['y']-y)<.001 for x,y,n in viaxy):
  n=v.get('source_net_id') or net(v.get('pcb_port_ids',[]));viaxy.append((v['x'],v['y'],n));geoms.append((n,[0,1],'circle',(v['x'],v['y'],VIA_RADIUS),.13))
def atvia(p):return p.get('virtual',False) or any(math.hypot(p['x']-v['x'],p['y']-v['y'])<.002 for v in manualvias)
def pid_layer(p,z):
 if p.get('virtual'):return None
 if z==0:return p['pointId']
 for v in manualvias:
  if math.hypot(p['x']-v['x'],p['y']-v['y'])<.002:
   for pid in v.get('pcb_port_ids',[]):
    if 'bottom' in ports.get(pid,{}).get('layers',[]):return pid
 return p['pointId']
def index(p,z=0):return (z*NY+round((p['y']-Y0)/STEP))*NX+round((p['x']-X0)/STEP)
def loc(i):z,i=divmod(i,N);y,x=divmod(i,NX);return x,y,z
# Paint exact expanded primitives in small NumPy windows.
def paint(mask,g,extra,forced=False):
 n,layers,typ,q,gap=g;r=extra+gap
 if typ=='rect':cx,cy,w,h,ang=q;rad=math.hypot(w,h)/2+r
 elif typ=='circle':cx,cy,rad=q;rad+=r
 else:ax,ay,bx,by,rr=q;cx=(ax+bx)/2;cy=(ay+by)/2;rad=math.hypot(bx-ax,by-ay)/2+rr+r
 ix0=max(0,int(math.floor((cx-rad-X0)/STEP)));ix1=min(NX,int(math.ceil((cx+rad-X0)/STEP))+1)
 iy0=max(0,int(math.floor((cy-rad-Y0)/STEP)));iy1=min(NY,int(math.ceil((cy+rad-Y0)/STEP))+1)
 if ix1<=ix0 or iy1<=iy0:return
 xx=xs[ix0:ix1][None,:];yy=ys[iy0:iy1][:,None]
 if typ=='rect':
  a=math.radians(ang);dx=(xx-cx)*math.cos(a)+(yy-cy)*math.sin(a);dy=-(xx-cx)*math.sin(a)+(yy-cy)*math.cos(a)
  area=np.maximum(np.abs(dx)-w/2,0)**2+np.maximum(np.abs(dy)-h/2,0)**2<=(r+1e-8)**2
 elif typ=='circle':area=(xx-cx)**2+(yy-cy)**2<=(rad+1e-8)**2
 else:
  ax,ay,bx,by,rr=q;d=(bx-ax)**2+(by-ay)**2
  u=np.clip(((xx-ax)*(bx-ax)+(yy-ay)*(by-ay))/max(d,1e-20),0,1)
  area=(xx-ax-u*(bx-ax))**2+(yy-ay-u*(by-ay))**2<=(rr+r+1e-8)**2
 for z in ([0,1] if forced else layers):mask[z,iy0:iy1,ix0:ix1]|=area

def base_mask(rad):
 xx=xs[None,:];yy=ys[:,None]
 # Rounded 50x35 rectangle, inset by edge clearance + copper radius.
 dx=np.maximum(np.abs(xx)-22.5,0);dy=np.maximum(np.abs(yy)-15,0)
 outside=(dx*dx+dy*dy>(2.5-.27-rad)**2)|(np.abs(xx)>25-.27-rad)|(np.abs(yy)>17.5-.27-rad)
 return np.broadcast_to(outside,(2,NY,NX)).copy()
def masks(n,width):
 block=base_mask(width/2);vblock=base_mask(VIA_RADIUS)
 for g in geoms:
  if g[0]!=n:paint(block,g,width/2);paint(vblock,g,VIA_RADIUS,True)
  elif g[0]=='__keepout':paint(block,g,width/2);paint(vblock,g,VIA_RADIUS,True)
 # A through via must avoid the lands of even its own net (no via in pad).
 for g in padgeoms:paint(vblock,(g[0],g[1],g[2],g[3],.16),VIA_RADIUS,True)
 for x,y,vn in viaxy:paint(vblock,(vn,[0,1],'circle',(x,y,VIA_RADIUS),.12),VIA_RADIUS,True)
 return block, vblock[0]|vblock[1]

counter=0;output=[]
def w(x,y,z,width):return dict(route_type='wire',x=round(x,7),y=round(y,7),width=width,layer=['top','bottom'][z])
def via(x,y,fr,to):return dict(route_type='via',x=round(x,7),y=round(y,7),from_layer=fr,to_layer=to,via_diameter=VIA_PAD,via_hole_diameter=VIA_HOLE)
def emit(n,a,b,path,width,plane=None):
 global counter
 rr=[];x,y,z=loc(path[0]);rr.append(w(a['x'],a['y'],z,width));
 if pid_layer(a,z):rr[0]['start_pcb_port_id']=pid_layer(a,z)
 # Retain corners and layer transitions; collinear points need no vertices.
 for k,i in enumerate(path):
  x,y,z=loc(i);prev=loc(path[k-1]) if k else None;nxt=loc(path[k+1]) if k+1<len(path) else None
  if prev and prev[2]!=z:
   rr.append(via(xs[x],ys[y],['top','bottom'][prev[2]],['top','bottom'][z]));rr.append(w(xs[x],ys[y],z,width))
  elif k==0 or nxt is None or nxt[2]!=z or (prev is not None and (x-prev[0],y-prev[1])!=(nxt[0]-x,nxt[1]-y)):
   rr.append(w(xs[x],ys[y],z,width))
 if plane:
  x,y,z=loc(path[-1]);rr.append(via(xs[x],ys[y],['top','bottom'][z],plane));toid=f'plane:{n}:{counter}'
 else:
  z=loc(path[-1])[2];rr.append(w(b['x'],b['y'],z,width));toid=b['pointId']
  if pid_layer(b,z):rr[-1]['end_pcb_port_id']=pid_layer(b,z)
 # Remove zero-length same-layer wires, retaining endpoint identities.
 clean=[]
 for p in rr:
  if clean and p['route_type']==clean[-1]['route_type']=='wire' and p['layer']==clean[-1]['layer'] and math.hypot(p['x']-clean[-1]['x'],p['y']-clean[-1]['y'])<.001:
   if p.get('end_pcb_port_id'):clean[-1]['end_pcb_port_id']=p['end_pcb_port_id']
  else:clean.append(p)
 t=dict(type='pcb_trace',pcb_trace_id=f'grid_{counter}',connection_name=n,connectsTo=[a['pointId'],toid],route=clean)
 if n in aliases:t['source_trace_id']=aliases[n]
 counter+=1;output.append(t);add_trace_geometry(t,n)

# A* on 0.05 mm grid, eight planar directions and legal through-via transitions.
dirs=[(1,0,1.),(-1,0,1.),(0,1,1.),(0,-1,1.),(1,1,1.414214),(1,-1,1.414214),(-1,1,1.414214),(-1,-1,1.414214)]
def search(a,b,n,width,plane=False):
 block,vblock=masks(n,width);flat=block.ravel();starts=[index(a,z) for z in ([0,1] if atvia(a) else [0])]
 tx=round((b['x']-X0)/STEP) if b else 0;ty=round((b['y']-Y0)/STEP) if b else 0
 ends={index(b,z) for z in ([0,1] if atvia(b) else [0])} if b else set()
 if any(flat[s] for s in starts) or (b and all(flat[e] for e in ends)):
  print('blocked terminal',names.get(n,n),a['pointId'],b['pointId'] if b else 'plane',flush=True);return None
 def h(x,y):
  if plane:return 0
  dx=abs(x-tx);dy=abs(y-ty);return max(dx,dy)+.414214*min(dx,dy)
 best={s:0. for s in starts};prev={};queue=[(h(*loc(s)[:2]),0.,s) for s in starts];heapq.heapify(queue);exp=0
 while queue:
  f,cost,i=heapq.heappop(queue)
  if cost!=best.get(i):continue
  x,y,z=loc(i);exp+=1
  if (plane and not vblock[y,x]) or (not plane and i in ends):
   path=[i]
   while i in prev:i=prev[i];path.append(i)
   return path[::-1]
  if plane and cost>64:continue
  for dx,dy,dist in dirs:
   xx=x+dx;yy=y+dy
   if not(0<=xx<NX and 0<=yy<NY):continue
   k=(z*NY+yy)*NX+xx
   if flat[k]:continue
   if dx and dy and (flat[(z*NY+y)*NX+xx] or flat[(z*NY+yy)*NX+x]):continue
   cc=cost+dist*(1.0 if (z==0 and dx) or (z==1 and dy) else 1.015)
   if cc<best.get(k,1e30):best[k]=cc;prev[k]=i;heapq.heappush(queue,(cc+h(xx,yy)*1.0,cc,k))
  if not plane and not vblock[y,x]:
   k=(1-z)*N+y*NX+x;cc=cost+32
   if not flat[k] and cc<best.get(k,1e30):best[k]=cc;prev[k]=i;heapq.heappush(queue,(cc+h(x,y)*1.0,cc,k))
  if exp>1500000:break
 print('no route',names.get(n,n),a['pointId'],b['pointId'] if b else 'plane','expanded',exp,flush=True);return None

def groups(n):
 pp=list(points[n].values());par=list(range(len(pp)));idx={p['pointId']:i for i,p in enumerate(pp)}
 def f(a):
  while par[a]!=a:a=par[a]
  return a
 for t in fixed:
  ids=t.get('connectsTo',[])
  if not ids:ids=[t['route'][0].get('start_pcb_port_id'),t['route'][-1].get('end_pcb_port_id')]
  ii=[idx[k] for k in ids if k in idx]
  for k in ii[1:]:par[f(k)]=f(ii[0])
 gg={}
 for i,p in enumerate(pp):gg.setdefault(f(i),[]).append(p)
 return list(gg.values())
# Four USB data contacts escape under the receptacle body, clear of C18.
for ni,pid,x in [('source_net_12','pcb_port_6',-20.),('source_net_13','pcb_port_7',-20.65),('source_net_12','pcb_port_8',-20.),('source_net_13','pcb_port_9',-20.65)]:
 p=points[ni].pop(pid);y=p['y'];vid='usb-escape:'+pid
 rr=[w(p['x'],y,0,.15),w(x,y,0,.15),via(x,y,'top','bottom')];rr[0]['start_pcb_port_id']=pid
 t=dict(type='pcb_trace',pcb_trace_id='grid_usb_'+pid,connection_name=ni,connectsTo=[pid,vid],route=rr)
 output.append(t);add_trace_geometry(t,ni)
 points[ni][vid]=dict(x=x,y=y,layer='bottom',pointId=vid,virtual=True)
# Escape active ESP32 signal lands toward the free area under the module.
u=next(e for e in C if e['type']=='source_component' and e['name']=='U1')
pc=next(e for e in C if e['type']=='pcb_component' and e.get('source_component_id')==u['source_component_id'])
for ni,pp in points.items():
 if names.get(ni) in ['GND','VDD_IO','USB_DM_MCU','USB_DP_MCU']:continue
 for pid,p in list(pp.items()):
  if ports.get(pid,{}).get('pcb_component_id')!=pc['pcb_component_id']:continue
  x,y=p['x'],p['y']
  if x<-4:x=-2.8
  elif x>13:x=11.8
  elif y<-8:y=-6.8
  else:continue
  vid='mcu-escape:'+pid;rr=[w(p['x'],p['y'],0,.15),w(x,y,0,.15),via(x,y,'top','bottom')];rr[0]['start_pcb_port_id']=pid
  t=dict(type='pcb_trace',pcb_trace_id='grid_mcu_'+pid,connection_name=ni,connectsTo=[pid,vid],route=rr)
  output.append(t);add_trace_geometry(t,ni);del pp[pid]
  pp[vid]=dict(x=x,y=y,layer='bottom',pointId=vid,virtual=True)
fail=[];start=time.time()
def route_net(n):
 if n not in points:return
 gg=groups(n);width=.3 if n in ['source_net_1','source_net_2','source_net_4','source_net_5'] else .2 if n in ['source_net_14','source_net_15'] else .15
 while len(gg)>1:
  options=sorted(((math.hypot(a['x']-b['x'],a['y']-b['y']),i,j,a,b) for i,g in enumerate(gg) for j,h in enumerate(gg) if i<j for a in g for b in h),key=lambda q:q[:3])
  success=False
  for _,i,j,a,b in options[:6]:
   path=search(a,b,n,width)
   if path:
    emit(n,a,b,path,width);gg[i]+=gg.pop(j);success=True;break
  if not success:fail.append((n,[[p['pointId'] for p in g] for g in gg]));break
 print('net',names.get(n,n),'routes',counter,'fail',len(fail),'sec',round(time.time()-start,1),flush=True)
# Reserve the regulator feedback escape before larger power-plane vias.
early_nets=[n for n in points if names.get(n) in ['BUCK_FB','CAM_FB']]
for n in early_nets:route_net(n)
# Local plane drops first, without inter-pad detours or redundant plane loops.
for n,plane in [('source_net_0','inner1'),('source_net_3','inner2')]:
 for group in groups(n):
  if any(atvia(p) for p in group):continue
  if n=='source_net_0' and any(1.15<=p['x']<=4.85 and -.05<=p['y']<=3.75 for p in group):continue
  # Prefer capacitor/pull-up terminals over module lands for plane connections.
  candidates=sorted(group,key=lambda p:len([o for o in I['obstacles'] if o.get('componentId')=='pcb_component_19' and p['pointId'] in o.get('connectedTo',[])]))
  ok=False
  for a in candidates:
   path=search(a,None,n,.2,True)
   if path:emit(n,a,None,path,.2,plane);ok=True;break
  if not ok:fail.append((n,[p['pointId'] for p in group]))
 print('plane',plane,'routes',counter,'fail',len(fail),flush=True)
# Route narrow-pitch USB first, then feedback/power, then camera and controls.
order=['source_net_10','source_net_11','source_net_12','source_net_13','source_net_14','source_net_15','source_net_7','source_net_8','source_net_42','source_net_43','source_net_1','source_net_2','source_net_5','source_net_4','source_net_9']+[f'source_net_{i}' for i in [37,36,33,32,31,30,29,28,27,26,25,24,23,22,21,20]]
order += ['source_net_41']
order += [n for n in points if n not in order and n not in ['source_net_0','source_net_3']]
for n in order:
 if n not in early_nets:route_net(n)
result={'traces':output,'failedConnections':fail}
for t in output:
 if not t.get('source_trace_id'):
  st=next((s for s in C if s['type']=='source_trace' and t['connection_name'] in s.get('connected_source_net_ids',[])),None)
  if st:t['source_trace_id']=st['source_trace_id']
json.dump(result,open(args.output,'w'),indent=2)
print('FINISHED',len(output),'routes;',len(fail),'failures;',round(time.time()-start,1),'s',flush=True)

if fail:sys.exit(1)