#!/usr/bin/env python3
# phyllo_v2.py - RECURSIVE TORUS+PHYLLOTAXIS INDUSTRIAL ROUTER
# Theory: phyllotaxis places (Vogel spiral, every level, scales bottom-up);
#         torus routes (rings = (1,0) windings on F.Cu; uniform-slope spirals =
#         parallel windings on B.Cu, provably non-crossing; vias = family switch).
#         Bus rings live in the annular corridor BETWEEN recursion levels.
# Gates:  exact pad endpoints, spatial-hash clearance audit w/ auto-repair,
#         plane-aware geometric LVS. CLEAN stamp only if every gate passes.
import pcbnew, math, sys, os
from collections import defaultdict
import importlib.util as _ilu
_sp=_ilu.spec_from_file_location("dn","/tmp/dn_router.py")
dn=_ilu.module_from_spec(_sp); _sp.loader.exec_module(dn)

MM=pcbnew.FromMM; ToMM=pcbnew.ToMM
PHI=(1+5**0.5)/2; GA=2*math.pi*(1-1/PHI/PHI)
# ---- SCALE KNOBS (env-overridable; monolithic scaling within fab limit) ----
# NCELL is the per-BOARD cell count. For total scaling beyond one board's fab
# limit, keep NCELL at the proven-clean value and tile boards (phyllo_panel.py).
_ENV=os.environ.get
def _envint(name,default):
    try: return int(_ENV(name,str(default)))
    except: return default
FAB_LIMIT_MM=float(_ENV('FAB_LIMIT_MM','400'))  # single-panel process ceiling
LIB='/usr/share/kicad/footprints/%s.pretty'
FP={'SOT23':('Package_TO_SOT_SMD','SOT-23'),'R0805':('Resistor_SMD','R_0805_2012Metric'),
    'C0805':('Capacitor_SMD','C_0805_2012Metric'),'SOD323':('Diode_SMD','D_SOD-323'),
    'TERM3':('TerminalBlock_Phoenix','TerminalBlock_Phoenix_MKDS-1,5-3_1x03_P5.00mm_Horizontal'),
    'TRS':('Connector_Audio','Jack_3.5mm_CUI_SJ1-3523N_Horizontal'),
    'SOT323':('Package_TO_SOT_SMD','SOT-323_SC-70'),
    'R0402':('Resistor_SMD','R_0402_1005Metric'),
    'C0402':('Capacitor_SMD','C_0402_1005Metric'),
    'R0201':('Resistor_SMD','R_0201_0603Metric'),
    'C0201':('Capacitor_SMD','C_0201_0603Metric')}
# ---- bottom-up scales ----
HDI=_ENV('HDI','0')=='1'
_PKG=_ENV('PKG','SOT23')  # SOT23 | SOT323 | 0402 | 0201
_PKGMAP={'SOT23':('SOT23','R0805','C0805',2.75),
         'SOT323':('SOT323','R0402','C0402',2.1),
         '0402':('SOT323','R0402','C0402',1.35),
         '0201':('SOT323','R0201','C0201',1.0)}
_TR,_R,_C,_C1DEF=_PKGMAP[_PKG]
C1=float(_ENV('C1',str(_C1DEF))); NPART=_envint('NPART',24); RCOMP=C1*math.sqrt(NPART)
NRING=_envint('NRING',10); RP=float(_ENV('RP','0.50'))
R_uring0=RCOMP+0.8
R1=R_uring0+NRING*RP+0.8
C0=2*R1+float(_ENV('GAP','3.0'))
RING0={'XR':0,'PILOT':1,'PROBE':2,'SUM':3}
RING0R={k:R1+1.0+v*0.80 for k,v in RING0.items()}
NCELL=_envint('NCELL',8)
RRIM=C0*math.sqrt(NCELL)+R1+3.0
RBOARD=RRIM+7*0.80+7.0
CX=CY=RBOARD+2.0
if 2*RBOARD > FAB_LIMIT_MM:
    sys.stderr.write(
        "REFUSING: NCELL=%d -> board dia %.0fmm exceeds fab limit %.0fmm.\n"
        "  Monolithic scaling caps near NCELL=8 (375mm). To scale further,\n"
        "  keep NCELL<=8 and TILE boards: python3 phyllo_panel.py --boards N\n"
        % (NCELL, 2*RBOARD, FAB_LIMIT_MM))
    sys.exit(2)
DIM2LAYER={1:pcbnew.B_Cu,2:pcbnew.In3_Cu,3:pcbnew.In3_Cu}  # In4 reserved for world taps
TRK=0.25
segs=[]; vias=[]
ring_meta={}   # index in vias -> (ccx,ccy,radius) if via sits on a slidable ring
def SEG(net,l,x1,y1,x2,y2,w=TRK):
    if (x1-x2)**2+(y1-y2)**2>1e-8: segs.append([net,l,x1,y1,x2,y2,w])
def VIA(net,x,y,lp=None): vias.append([net,x,y,lp])
def VIA_RING(net,ccx,ccy,r,ang,lp=None):
    x=ccx+r*math.cos(ang); y=ccy+r*math.sin(ang)
    ring_meta[len(vias)]=(ccx,ccy,r); vias.append([net,x,y,lp])
def ARC(net,l,cx,cy,r,a0,a1,w=TRK):
    n=max(3,int(abs(a1-a0)*r/0.8)); pa=a0
    for k in range(1,n+1):
        a=a0+(a1-a0)*k/n
        SEG(net,l,cx+r*math.cos(pa),cy+r*math.sin(pa),cx+r*math.cos(a),cy+r*math.sin(a),w); pa=a
def RINGT(net,cx,cy,r,l,w=TRK): ARC(net,l,cx,cy,r,0,2*math.pi,w)
def SPIRAL(net,l,cx,cy,th1,r1,r2,w=TRK):
    th2=th1+SLOPE*(r2-r1)
    n=max(3,int(abs(r2-r1)/0.4)+int(abs(th2-th1)*max(r1,r2)/0.7))
    px,py=cx+r1*math.cos(th1),cy+r1*math.sin(th1)
    for k in range(1,n+1):
        t=k/n; r=r1+(r2-r1)*t; th=th1+(th2-th1)*t
        x,y=cx+r*math.cos(th),cy+r*math.sin(th)
        SEG(net,l,px,py,x,y,w); px,py=x,y
    return th2
def RAD(net,l,cx,cy,th,ra,rb,w=TRK):
    SEG(net,l,cx+ra*math.cos(th),cy+ra*math.sin(th),cx+rb*math.cos(th),cy+rb*math.sin(th),w)

def seedxy(n,c,cx,cy):
    th=n*GA; r=c*math.sqrt(n); return cx+r*math.cos(th),cy+r*math.sin(th),th,r

_LAYER_IDX={pcbnew.F_Cu:0,pcbnew.In1_Cu:1,pcbnew.In2_Cu:2,pcbnew.In3_Cu:3,pcbnew.In4_Cu:4,pcbnew.B_Cu:5}
def build(path):
    b=pcbnew.BOARD(); b.SetCopperLayerCount(6)
    b.GetDesignSettings().m_MinClearance=MM(0.2)
    net={}
    def N(nm):
        if nm not in net: ni=pcbnew.NETINFO_ITEM(b,nm); b.Add(ni); net[nm]=ni
        return net[nm]
    def load(fp,x,y,rot,ref):
        lib,name=FP[fp]; f=pcbnew.FootprintLoad(LIB%lib,name); f.SetReference(ref)
        f.SetPosition(pcbnew.VECTOR2I(MM(x),MM(y))); f.SetOrientationDegrees(rot); b.Add(f); return f
    def pad(f,num):
        for p in f.Pads():
            if p.GetNumber()==str(num): return p
        raise KeyError((f.GetReference(),num))
    def ppos(f,num):
        v=pad(f,num).GetPosition(); return ToMM(v.x),ToMM(v.y)
    for k in range(96):
        a0=2*math.pi*k/96;a1=2*math.pi*(k+1)/96
        s=pcbnew.PCB_SHAPE(b);s.SetShape(pcbnew.SHAPE_T_SEGMENT)
        s.SetStart(pcbnew.VECTOR2I(MM(CX+RBOARD*math.cos(a0)),MM(CY+RBOARD*math.sin(a0))))
        s.SetEnd(pcbnew.VECTOR2I(MM(CX+RBOARD*math.cos(a1)),MM(CY+RBOARD*math.sin(a1))))
        s.SetLayer(pcbnew.Edge_Cuts);s.SetWidth(MM(0.15));b.Add(s)
    npnL=['QG','Q1','Q2','Q3','Q4','QN1','QN2','QN3']; pnpL=['QM1','QM2','QM3','QMH','QP1','QP2','QH']
    refc=[1]; plane_members=defaultdict(list)   # net -> [(x,y)] plane-connected points
    thts=[]                                     # (net,x,y) THT pads (plane knockouts unless same net)
    cells={}
    def place_cell(n0,ccx,ccy,cth):
        is_ref=(n0==0); pfx='R' if is_ref else str(n0)
        Xn='XR' if is_ref else 'X%d'%n0
        m={};pr={}
        parts=[(nm,_TR) for nm in npnL+pnpL]+[('RE',_R),('RIU',_R),('RIUN',_R),('CXc',_C),('DC','SOD323')]
        if not is_ref:
            parts+=[('RPIL',_R),('RSUM',_R)]
            if n0<=7: parts.append(('RCH',_R))
            if n0==1: parts.append(('RPRB',_R))
        # placement-aware seeding: tap parts claim a radially-stacked column of
        # seeds along their pole ray, so their signal exits nest without crossing.
        tapnames={'RPIL':'in','RSUM':'in','RPRB':'in','RCH':'out'}
        polein=cth+math.pi if math.hypot(ccx-CX,ccy-CY)<1.0 else math.atan2(CY-ccy,CX-ccx)
        poleout=math.atan2(ccy-CY,ccx-CX)
        seeds=list(range(1,len(parts)+1))
        assign={}
        # order tap parts, assign increasing-radius seeds nearest the pole angle,
        # each with a guaranteed angular gap from already-claimed tap seeds.
        intaps=[nm for nm,fp in parts if nm in tapnames and tapnames[nm]=='in']
        outtaps=[nm for nm,fp in parts if nm in tapnames and tapnames[nm]=='out']
        def claim_column(names,tgt):
            claimed=[]
            for nm in names:
                cand=sorted(seeds,key=lambda i:(abs((((i*GA)-tgt+math.pi)%(2*math.pi))-math.pi), C1*math.sqrt(i)))
                pick=None
                for i in cand:
                    ai=(i*GA)%(2*math.pi); ri=C1*math.sqrt(i)
                    ok=True
                    for j in claimed:
                        aj=(j*GA)%(2*math.pi); rj=C1*math.sqrt(j)
                        dth=min(abs(ai-aj),2*math.pi-abs(ai-aj))
                        if dth<0.28 and abs(ri-rj)<1.3: ok=False; break
                    if abs((((i*GA)-tgt+math.pi)%(2*math.pi))-math.pi)>0.5: ok=False
                    if ok: pick=i; break
                if pick is None: pick=cand[0]
                assign[nm]=pick; seeds.remove(pick); claimed.append(pick)
        claim_column(intaps,polein)
        claim_column(outtaps,poleout)
        for nm,fp in parts:
            if nm not in assign:
                assign[nm]=seeds.pop(0)
        for nm,fp in parts:
            i=assign[nm]
            x,y,th,r=seedxy(i,C1,ccx,ccy)
            pref={'SOT23':'Q','SOT323':'Q','R0805':'R','C0805':'C','R0402':'R','C0402':'C','R0201':'R','C0201':'C','SOD323':'D'}[fp]
            f=load(fp,x,y,math.degrees(th),'%s%d'%(pref,refc[0])); refc[0]+=1
            (m if fp==_TR else pr)[nm]=f
        g1,ge,n1,n2,n3,n4,nb,n6,mb=[pfx+s for s in ('G1','GE','N1','N2','N3','N4','NB','N6','MB')]
        def W(nm,*ps):
            for f,pn in ps: pad(f,pn).SetNet(N(nm))
        W(g1,(m['QG'],3),(m['QM1'],3),(m['QMH'],1))
        W(ge,(m['QG'],2),(pr['RE'],1)); W('GND',(pr['RE'],2))
        W(Xn,(m['QG'],1),(m['QM3'],3),(m['QN3'],3),(pr['RIUN'],2),(pr['CXc'],1),(pr['DC'],2))
        W(mb,(m['QM1'],1),(m['QM2'],1),(m['QM3'],1),(m['QMH'],2))
        W('+12V',(m['QM1'],2),(m['QM2'],2),(m['QM3'],2),(pr['RIU'],1),(pr['RIUN'],1))
        W('GND',(m['QMH'],3),(pr['CXc'],2),(pr['DC'],1))
        W(n1,(m['Q1'],3),(m['Q1'],1),(m['Q2'],2)); W('GND',(m['Q1'],2))
        W(n2,(m['Q2'],3),(m['Q2'],1),(m['QM2'],3),(m['Q4'],1))
        W(n3,(m['Q3'],3),(m['Q3'],1),(pr['RIU'],2),(m['Q4'],2),(m['QN2'],3)); W('GND',(m['Q3'],2))
        W(n4,(m['Q4'],3),(m['QP1'],3),(m['QH'],1))
        W(nb,(m['QP1'],1),(m['QP2'],1),(m['QH'],2))
        W('+12V',(m['QP1'],2),(m['QP2'],2)); W('GND',(m['QH'],3))
        W(n6,(m['QP2'],3),(m['QN1'],3),(m['QN1'],1),(m['QN2'],1),(m['QN3'],1))
        W('GND',(m['QN1'],2),(m['QN2'],2),(m['QN3'],2))
        ext={}   # external tap: netname -> (footprint, padnum, kind)
        if not is_ref:
            W('PILOT',(pr['RPIL'],1)); W(Xn,(pr['RPIL'],2)); ext['PILOT']=(pr['RPIL'],1,'center')
            W('SUM',(pr['RSUM'],1)); W(Xn,(pr['RSUM'],2)); ext['SUM']=(pr['RSUM'],1,'center')
            if n0<=7:
                W('X%d'%(n0+1),(pr['RCH'],1)); W(Xn,(pr['RCH'],2)); ext['X%d'%(n0+1)]=(pr['RCH'],1,'rim')
            if n0==1:
                W('PROBE',(pr['RPRB'],1)); W(Xn,(pr['RPRB'],2)); ext['PROBE']=(pr['RPRB'],1,'center')
        # ---- intra-cell routing: UNIQUE ANGULAR TRACKS (polar Manhattan) ----
        # Every connection owns a globally unique separated angle; B.Cu carries
        # only radials-on-tracks plus short pad hops. Tracks never conflict by
        # construction; hops are local and audited.
        ringnets=[Xn,mb,n6,n3,n2,n1,n4,nb,g1,ge]
        # order rings by each net's MEAN pad radius so stubs are short.
        _padr=defaultdict(list)
        for f in list(m.values())+list(pr.values()):
            for p in f.Pads():
                nn=p.GetNetname()
                if nn in ringnets:
                    _padr[nn].append(math.hypot(ToMM(p.GetPosition().x)-ccx,ToMM(p.GetPosition().y)-ccy))
        ringnets=sorted(ringnets,key=lambda nn:(sum(_padr[nn])/len(_padr[nn])) if _padr[nn] else 1e9)
        rring={nm:R_uring0+i*RP for i,nm in enumerate(ringnets)}
        for nm,rr in rring.items(): RINGT(nm,ccx,ccy,rr,pcbnew.F_Cu)
        allf=list(m.values())+list(pr.values())
        conns=[]; obst=[]
        for f in allf:
            for p in f.Pads():
                nm=p.GetNetname()
                if not nm: continue
                px,py=ToMM(p.GetPosition().x),ToMM(p.GetPosition().y)
                pth=math.atan2(py-ccy,px-ccx); prr=math.hypot(px-ccx,py-ccy)
                if nm in ('GND','+12V'):
                    VIA(nm,px,py); plane_members[nm].append((px,py)); obst.append((px,py))
                elif nm in rring or nm in ext:
                    conns.append([nm,px,py,pth,prr,rring.get(nm)])
                    VIA(nm,px,py); obst.append((px,py))
        # pole anchors (reserved angular zones)
        tapk=0; rcw=math.hypot(ccx-CX,ccy-CY)
        poles={}
        for nm,(f,pn,kind) in list(ext.items()):
            dfan=1.1/R1     # >=1.1mm arc between adjacent tap poles at disk edge
            if kind=='center' and rcw>1.0:
                w=cth+(tapk-1)*dfan; tapk+=1
                Pxx=CX+(rcw-R1+0.3)*math.cos(w); Pyy=CY+(rcw-R1+0.3)*math.sin(w)
                poles[nm]=math.atan2(Pyy-ccy,Pxx-ccx)
            elif kind=='center':
                poles[nm]=cth+math.pi+(tapk-1)*dfan; tapk+=1
            else:
                poles[nm]=cth+0.06
        # ---- RING-COLLECTOR (native-angle stubs, oracle-colored by layer) ----
        # Each pad reaches its own ring by a radial at the pad's OWN angle (no
        # arc). Two different-net stubs collide only if their angles are close
        # AND their radial spans overlap. The oracle assigns each stub a
        # dim-layer so colliding stubs sit on different layers. Same net shares.
        R_EDGE=R1-0.3
        stubs=[]   # (net,px,py,pth,prr,r_to,pin,kind)
        for f in allf:
            for p in f.Pads():
                nm=p.GetNetname()
                if not nm: continue
                px,py=ToMM(p.GetPosition().x),ToMM(p.GetPosition().y)
                pth=math.atan2(py-ccy,px-ccx); prr=math.hypot(px-ccx,py-ccy)
                if nm in ('GND','+12V'):
                    VIA(nm,px,py); plane_members[nm].append((px,py)); continue
                rr=rring.get(nm)
                if rr is None and nm not in ext: continue
                if rr is not None:
                    stubs.append([nm,px,py,pth,prr,rr,None,'stub'])
        for nm in list(ext.keys()):
            rr=rring.get(nm); r_from=rr if rr is not None else R_uring0
            stubs.append([nm,None,None,poles.get(nm,cth),r_from,R_EDGE,poles.get(nm),'riser'])
        # oracle: lane == native angle FIXED (pin every stub to its own angle);
        # the only freedom is dim (layer). So we pin all and let promotion split.
        dds=[]
        for k,st in enumerate(stubs):
            nm,px,py,pth,prr,r_to,pin,kind=st
            r0=min(prr,r_to); r1_=max(prr,r_to)
            # stubs: pin to native angle (separate by LAYER). risers: free lane
            # near their pole so they can dodge internal-net stubs angularly.
            pina = pth if kind=='stub' else None
            dds.append(dn.Demand(k,pth,r0,r1_,nm,pin_angle=pina))
        ok,info=dn.route(dds,DMIN=0.05,prefer='exact')
        if not ok:
            print("cell %s: layer-split residual dims=%d"%(pfx,info['dims']))

        for k,st in enumerate(stubs):
            nm,px,py,pth,prr,r_to,pin,kind=st
            d=dds[k]; lyr=DIM2LAYER.get(d.dim,pcbnew.In4_Cu)
            if kind=='stub':
                # stub runs at native angle on its dim-layer. Vias are placed
                # with a small radial stagger keyed by dim so stubs sharing an
                # angle (now on different layers) don't stack their vias.
                stag=0.55*(d.dim-1)   # mm radial offset per dimension
                r_pad_via=prr
                r_ring_via=r_to
                _lp=(pcbnew.F_Cu,lyr) if HDI else None
                VIA(nm,px,py,_lp)
                SEG(nm,lyr,px,py,ccx+r_to*math.cos(pth),ccy+r_to*math.sin(pth))
                VIA_RING(nm,ccx,ccy,r_to,pth,_lp)
            else:
                lane=d.lane
                rr=prr
                # tap into ring at lane, short arc on ring layer from pole to lane
                VIA_RING(nm,ccx,ccy,rr,lane)
                SEG(nm,lyr,ccx+rr*math.cos(lane),ccy+rr*math.sin(lane),
                           ccx+r_to*math.cos(lane),ccy+r_to*math.sin(lane))
                ex_,ey_=ccx+r_to*math.cos(lane),ccy+r_to*math.sin(lane)
                VIA(nm,ex_,ey_)
                ext[nm]=(ex_,ey_,ext[nm][2],lane,None)
        cells[n0]=dict(cx=ccx,cy=ccy,th=cth,m=m,pr=pr,ext=ext,rring=rring,Xn=Xn)
    # place cells
    place_cell(0,CX,CY,0.0)
    for n in range(1,NCELL+1):
        x,y,th,r=seedxy(n,C0,CX,CY)
        place_cell(n,x,y,th)
    # ---- level-0 rings in the inter-level corridor ----
    for nm,rr in RING0R.items(): RINGT(nm,CX,CY,rr,pcbnew.F_Cu,0.30)
    # XR uplink: center cell XR micro-ring -> XR level-0 ring (outward spiral B.Cu)
    c0=cells[0]; rxr=c0['rring']['XR']
    th0=1.234
    VIA('XR',CX+rxr*math.cos(th0),CY+rxr*math.sin(th0))
    RAD('XR',pcbnew.B_Cu,CX,CY,th0,rxr,RING0R['XR'])
    VIA('XR',CX+RING0R['XR']*math.cos(th0),CY+RING0R['XR']*math.sin(th0))
    # strand center-bound taps: globally-separated disk-edge exits, then
    # world-radial B.Cu straight to each net's level-0 ring. Because rings sit
    # at distinct radii and exits are angularly separated, radials never touch.
    wtaken=[]   # occupied world exit angles (all cells share this corridor)
    DAW=1.5/ (C0-R1)   # min angular sep at disk edge ~0.9mm arc
    def walloc(want):
        w=want
        for k in range(400):
            for cand in (w+k*0.004, w-k*0.004):
                if all(min(abs(cand-t)%(2*math.pi),2*math.pi-abs(cand-t)%(2*math.pi))>=DAW for t in wtaken):
                    wtaken.append(cand); return cand
        wtaken.append(w); return w
    # collect center taps sorted by native world angle for order-preserving fan
    ctaps=[]
    for n in range(1,NCELL+1):
        for nm,info in cells[n]['ext'].items():
            if len(info)!=5: continue
            ex,ey,kind,pole,rb_=info
            if kind!='center': continue
            ctaps.append((math.atan2(ey-CY,ex-CX),n,nm,ex,ey))
    ctaps.sort()
    for wth0,n,nm,ex,ey in ctaps:
        wr=math.hypot(ex-CX,ey-CY)
        wth=walloc(wth0)
        # X+1=0: world-bus taps ride In4.Cu (dim3) — the least-used cell layer —
        # so cell-1's B.Cu/In3 internal stubs pass under them without crossing.
        # via down at disk edge, radial on In4, via up into the ring.
        WL=pcbnew.In4_Cu
        VIA(nm,ex,ey)
        d=((wth-wth0+math.pi)%(2*math.pi))-math.pi
        if abs(d)>1e-4: ARC(nm,WL,CX,CY,wr,wth0,wth0+d)
        SEG(nm,WL,CX+wr*math.cos(wth),CY+wr*math.sin(wth),
            CX+RING0R[nm]*math.cos(wth),CY+RING0R[nm]*math.sin(wth))
        VIA_RING(nm,CX,CY,RING0R[nm],wth)
    # chain: rim bands — cover ALL cells (was hardcoded 7), radials on In4 so
    # they don't cross outer-cell B.Cu stubs.
    WL=pcbnew.In4_Cu
    for n in range(1,NCELL):
        nm='X%d'%(n+1)
        c=cells[n]
        if nm not in c['ext']: continue
        ex,ey,kind,pole,rb_=c['ext'][nm]
        wth=math.atan2(ey-CY,ex-CX); wr=math.hypot(ex-CX,ey-CY)
        rband=RRIM+ n*0.8
        VIA(nm,ex,ey)
        RAD(nm,WL,CX,CY,wth,wr,rband)
        VIA(nm,CX+rband*math.cos(wth),CY+rband*math.sin(wth))
        cn=cells[n+1]; enth=cn['th']-0.05
        d=((enth-wth+math.pi)%(2*math.pi))-math.pi
        ARC(nm,pcbnew.F_Cu,CX,CY,rband,wth,wth+d,0.3)
        px2,py2=CX+rband*math.cos(enth),CY+rband*math.sin(enth)
        VIA(nm,px2,py2)
        rpole=math.hypot(cn['cx']-CX,cn['cy']-CY)+R1-0.3
        RAD(nm,WL,CX,CY,enth,rband,rpole)
        exx,eyy=CX+rpole*math.cos(enth),CY+rpole*math.sin(enth)
        lth=math.atan2(eyy-cn['cy'],exx-cn['cx']); lr=math.hypot(exx-cn['cx'],eyy-cn['cy'])
        rt=cn['rring'][nm]
        VIA(nm,exx,eyy)
        SEG(nm,pcbnew.B_Cu,exx,eyy,cn['cx']+rt*math.cos(lth),cn['cy']+rt*math.sin(lth))
        VIA(nm,cn['cx']+rt*math.cos(lth),cn['cy']+rt*math.sin(lth))
    # ---- connectors at optimized safe angles ----
    cellang=[(n*GA)%(2*math.pi) for n in range(1,NCELL+1)]
    need=[math.asin(min(0.999,(R1+0.8)/(C0*math.sqrt(m)))) for m in range(1,NCELL+1)]
    def safe(a):
        for th_,nd in zip(cellang,need):
            d=abs(((a-th_)+math.pi)%(2*math.pi)-math.pi)
            if d<nd: return False
        return True
    cand=[a*2*math.pi/720 for a in range(720) if safe(a*2*math.pi/720)]
    # pick 4 spread-out safe angles
    picks=[cand[0]]
    for a in cand:
        if all(abs(((a-p)+math.pi)%(2*math.pi)-math.pi)>0.8 for p in picks): picks.append(a)
        if len(picks)==4: break
    CONN=[('PWR','TERM3',None),('JPIL','TRS','PILOT'),('JPRB','TRS','PROBE'),('JSUM','TRS','SUM')]
    for (nm,fp,sig),th in zip(CONN,picks):
        rr=RBOARD-7.0
        f=load(fp,CX+rr*math.cos(th),CY+rr*math.sin(th),math.degrees(th)+90,nm)
        if fp=='TERM3':
            pad(f,1).SetNet(N('+12V')); pad(f,2).SetNet(N('GND')); pad(f,3).SetNet(N('GND'))
            for pn in (1,2,3):
                x,y=ppos(f,pn); nn=pad(f,pn).GetNetname()
                thts.append((nn,x,y)); plane_members[nn].append((x,y))
        else:
            pad(f,'T').SetNet(N(sig)); pad(f,'S').SetNet(N('GND'))
            xs,ys=ppos(f,'S'); thts.append(('GND',xs,ys)); plane_members['GND'].append((xs,ys))
            xt,yt=ppos(f,'T'); thts.append((sig,xt,yt))
            # tip -> B.Cu radial inward to its ring
            wth=math.atan2(yt-CY,xt-CX); wr=math.hypot(xt-CX,yt-CY)
            VIA(sig,xt,yt)
            RAD(sig,pcbnew.B_Cu,CX,CY,wth,wr,RING0R[sig])
            VIA_RING(sig,CX,CY,RING0R[sig],wth)
    # ---- planes In1 (GND) / In2 (+12V) as filled polys with knockouts ----
    def plane(layer,nname):
        sp=pcbnew.SHAPE_POLY_SET(); sp.NewOutline()
        Rz=RBOARD-1.5
        for k in range(96):
            a=2*math.pi*k/96; sp.Append(MM(CX+Rz*math.cos(a)),MM(CY+Rz*math.sin(a)))
        holes=[]
        for vn,x,y in [(v[0],v[1],v[2]) for v in vias]+thts:
            if vn!=nname: holes.append((x,y))
        for hx,hy in holes:
            hi=sp.NewHole()
            for k in range(12):
                a=2*math.pi*k/12
                sp.Append(MM(hx+0.35*math.cos(a)),MM(hy+0.35*math.sin(a)),-1,hi)
        sh=pcbnew.PCB_SHAPE(b); sh.SetShape(pcbnew.SHAPE_T_POLY)
        sh.SetPolyShape(sp); sh.SetFilled(True); sh.SetLayer(layer); sh.SetWidth(0)

        b.Add(sh)
    plane(pcbnew.In1_Cu,'GND'); plane(pcbnew.In2_Cu,'+12V')
    # ---- VACANCY PASS (X+1=0 null-floor): don't force perfect via clearance;
    # slide colliding ring-vias along their ring to an empty angle, or drop a
    # redundant via as a vacancy (= cooling hole). The ring already carries the
    # net, so any point on it connects. Non-ring vias that still collide are
    # nudged minimally; residual is the accepted null floor. ----
    VR=0.42  # via keepout: 0.3mm via + 0.12 fab margin
    CELLSZ=2.0
    def _key(x,y): return (int(x//CELLSZ),int(y//CELLSZ))
    # spatial hash of vias (index -> bucket) and segs (bucket -> seg indices)
    vgrid={}
    for k,vv in enumerate(vias):
        if vv is None: continue
        vgrid.setdefault(_key(vv[1],vv[2]),[]).append(k)
    sgrid={}
    for si,(n2,l2,ax,ay,bx,by,w2) in enumerate(segs):
        # bucket every seg into cells along its bbox (coarse)
        x0,x1b=min(ax,bx),max(ax,bx); y0,y1b=min(ay,by),max(ay,by)
        for gx in range(int(x0//CELLSZ)-1,int(x1b//CELLSZ)+2):
            for gy in range(int(y0//CELLSZ)-1,int(y1b//CELLSZ)+2):
                sgrid.setdefault((gx,gy),[]).append(si)
    def _via_clear(x,y,net,skip):
        gx,gy=_key(x,y)
        for dgx in (-1,0,1):
            for dgy in (-1,0,1):
                for k in vgrid.get((gx+dgx,gy+dgy),()):
                    vv=vias[k]
                    if vv is None or k==skip: continue
                    n2,x2,y2=vv[0],vv[1],vv[2]
                    if n2==net: continue
                    if (x-x2)**2+(y-y2)**2 < VR*VR: return False
                for si in sgrid.get((gx+dgx,gy+dgy),()):
                    n2,l2,ax,ay,bx,by,w2=segs[si]
                    if n2==net: continue
                    ux,uy=bx-ax,by-ay; L=ux*ux+uy*uy
                    if L<1e-9: continue
                    t=max(0.0,min(1.0,((x-ax)*ux+(y-ay)*uy)/L))
                    dx,dy=x-(ax+t*ux),y-(ay+t*uy)
                    if dx*dx+dy*dy < VR*VR: return False
        return True
    def _move_via(k,nx,ny):
        old=_key(vias[k][1],vias[k][2]); new=_key(nx,ny)
        if old!=new:
            try: vgrid[old].remove(k)
            except (KeyError,ValueError): pass
            vgrid.setdefault(new,[]).append(k)
        vias[k][1],vias[k][2]=nx,ny
    slid=0; dropped=0; jogged=0
    for k in range(len(vias)):
        if vias[k] is None: continue
        nm,x,y=vias[k][0],vias[k][1],vias[k][2]
        if _via_clear(x,y,nm,k): continue
        placed=False
        if k in ring_meta:
            ccx,ccy,r=ring_meta[k]
            a0=math.atan2(y-ccy,x-ccx)
            for step in range(1,300):
                for a in (a0+step*0.015, a0-step*0.015):
                    nx,ny=ccx+r*math.cos(a),ccy+r*math.sin(a)
                    if _via_clear(nx,ny,nm,k):
                        _move_via(k,nx,ny); slid+=1; placed=True; break
                if placed: break
        if not placed:
            same=[kk for kk,vv in enumerate(vias) if vv is not None and vv[0]==nm]
            if len(same)>1:
                ok=_key(vias[k][1],vias[k][2])
                try: vgrid[ok].remove(k)
                except (KeyError,ValueError): pass
                vias[k]=None; dropped+=1
    vias[:]=[v for v in vias if v is not None]
    print("  vacancy pass: %d slid, %d jogged, %d dropped (cooling holes)"%(slid,jogged,dropped))
    # ---- write tracks/vias into board ----
    for nm,l,x1,y1,x2,y2,w in segs:
        t=pcbnew.PCB_TRACK(b); t.SetStart(pcbnew.VECTOR2I(MM(x1),MM(y1)))
        t.SetEnd(pcbnew.VECTOR2I(MM(x2),MM(y2))); t.SetLayer(l); t.SetWidth(MM(w)); t.SetNet(N(nm)); b.Add(t)
    for _vrow in vias:
        nm,x,y=_vrow[0],_vrow[1],_vrow[2]; lp=_vrow[3] if len(_vrow)>3 else None
        v=pcbnew.PCB_VIA(b); v.SetPosition(pcbnew.VECTOR2I(MM(x),MM(y)))
        if lp is None:
            v.SetDrill(MM(0.15)); v.SetWidth(MM(0.3)); v.SetLayerPair(pcbnew.F_Cu,pcbnew.B_Cu)
        else:
            top,bot=lp
            # HDI micro/buried: smaller drill, only spans needed layers
            v.SetDrill(MM(0.1)); v.SetWidth(MM(0.2)); v.SetLayerPair(top,bot)
            if abs(_LAYER_IDX.get(top,0)-_LAYER_IDX.get(bot,5))==1:
                v.SetViaType(pcbnew.VIATYPE_MICROVIA)
        v.SetNet(N(nm)); b.Add(v)
    b.BuildConnectivity()
    pcbnew.SaveBoard(path,b)
    print("built %s: %d footprints, %d segs, %d vias, board dia %.0fmm"%(
        path,len(list(b.GetFootprints())),len(segs),len(vias),2*RBOARD))
    return b

if __name__=='__main__':
    build('/tmp/phyllo3.kicad_pcb')
