main
John Lauer Publish 0.1.26 0b71841
#!/usr/bin/env python3
"""Repair a reviewed layout using atomic reference/value candidates.
Input: labels with native normalized offsets, board outline, fixed obstacle polygons,
component anchors and body polygons. Existing non-pair labels are reserved unchanged.
Prior positions are explicit candidates, not represented as a fresh solve.
"""
import argparse,json,math,importlib.util
from pathlib import Path
from collections import defaultdict
from shapely.geometry import shape,box,Point
from shapely.ops import unary_union
from shapely.prepared import prep

def generate(d):
 labels=d['labels'];outline=shape(d['outline']);pairs=defaultdict(list)
 for i,l in enumerate(labels):
  if l.get('reference') and not l.get('pin') and l['layer']=='F.SilkS' and l['reference'].startswith(('R','C')):pairs[l['reference']].append(i)
 pairs={r:ids for r,ids in pairs.items() if len(ids)==2 and any('value' in labels[i]['role'] for i in ids)}
 paired={i for ids in pairs.values() for i in ids}
 fixed=prep(unary_union([shape(o) for o in d['obstacles']]+([] if d.get('fresh') else [box(*l['box']) for i,l in enumerate(labels) if l['layer']=='F.SilkS' and i not in paired])))
 def bounds(i,size,angle):
  l=labels[i];o=l['offset'];a=math.radians(l.get('angle',0)-angle);ps=[((u*math.cos(a)-v*math.sin(a))*size,(u*math.sin(a)+v*math.cos(a))*size) for u,v in [(o[0],o[1]),(o[0],o[3]),(o[2],o[1]),(o[2],o[3])]];xs,ys=zip(*ps);return[min(xs),min(ys),max(xs),max(ys)]
 groups=[]
 for ref,ids in pairs.items():
  ids.sort(key=lambda i:'value' in labels[i]['role']);anchor=d['parts'][ref]['anchor'];body=shape(d['parts'][ref]['body']);cs=[]
  def candidate(children,origin):
   gs=[box(*c['box']) for c in children]
   if any(not outline.contains(g) or fixed.intersects(g) for g in gs) or gs[0].buffer(.02).intersects(gs[1]):return
   if any(g.distance(body)>1.5 for g in gs):return
   bb=unary_union(gs).bounds;center=[(bb[0]+bb[2])/2,(bb[1]+bb[3])/2];separation=gs[0].distance(gs[1]);fs,vs=[c['size'] for c in children]
   cs.append(dict(font=round(fs+vs*.2,4),position=center,box=list(bb),boxes=[c['box'] for c in children],children=children,origin=origin,qualityPenalty=separation*12+(0 if children[0]['angle']==children[1]['angle'] else 2)))
  def child(i,size,angle,x,y):
   b=bounds(i,size,angle);return dict(index=i,reference=ref,text=labels[i]['text'],role='value' if 'value' in labels[i]['role'] else 'reference',layer='F.SilkS',size=size,angle=angle,x=x,y=y,box=[b[0]+x,b[1]+y,b[2]+x,b[3]+y])
  # Reviewed locations remain a repair candidate only when hierarchy is satisfied.
  for fs in [.8,.7,.6,.5,.4,.35,.3,.25]:
   if fs<.25:continue
   for vs in [.4,.35,.3,.25,.2]:
    if vs>fs*.8:continue
    if not d.get('fresh'):candidate([child(i,s,labels[i].get('angle',0),labels[i]['x'],labels[i]['y']) for i,s in zip(ids,[fs,vs])],'reviewed-position repair')
    for angle in [0,90]:
     bs=[bounds(i,s,angle) for i,s in zip(ids,[fs,vs])]
     for horizontal in [False,True]:
      w=[b[2]-b[0] for b in bs];h=[b[3]-b[1] for b in bs]
      # Shared edge/alignment; secondary value always smaller and adjacent.
      centers=[(0,0),(0,(h[0]+h[1])/2+.08)] if not horizontal else [(0,0),((w[0]+w[1])/2+.10,0)]
      local=[(cx-(b[0]+b[2])/2,cy-(b[1]+b[3])/2) for (cx,cy),b in zip(centers,bs)]
      bb=unary_union([box(b[0]+x,b[1]+y,b[2]+x,b[3]+y) for b,(x,y) in zip(bs,local)]).bounds
      x0,y0,x1,y1=body.bounds
      for gap in [.06,.2,.45]:
       for shift in [0,-.25,.25,-.5,.5,-.8,.8]:
        positions=[(anchor[0]+shift-(bb[0]+bb[2])/2,y0-gap-bb[3]),(anchor[0]+shift-(bb[0]+bb[2])/2,y1+gap-bb[1]),(x0-gap-bb[2],anchor[1]+shift-(bb[1]+bb[3])/2),(x1+gap-bb[0],anchor[1]+shift-(bb[1]+bb[3])/2)]
        for x,y in positions:candidate([child(i,s,angle,x+dx,y+dy) for i,s,(dx,dy) in zip(ids,[fs,vs],local)],'joint pair search')
  # Keep spatial diversity so neighboring groups can negotiate room.
  buckets={}
  for c in cs:
   key=(c['font'],round(c['position'][0]*2),round(c['position'][1]*2));score=c['qualityPenalty']+math.dist(c['position'],anchor)
   if key not in buckets or score<buckets[key][0]:buckets[key]=(score,c)
  cs=[v[1] for v in buckets.values()];groups.append(dict(id=ref,anchor=anchor,candidates=cs,requiredRoles=['reference','value'],maxValueRatio=.8));print(ref,len(cs),flush=True)
 return dict(bounds=list(outline.bounds),labels=groups,clearance=.025,solver='milp',solverSeconds=60,milpPerFont=5,candidatesPerLabel=1000,fontWeight=20,distanceWeight=3)
if __name__=='__main__':
 p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--out',required=True);a=p.parse_args();Path(a.out).write_text(json.dumps(generate(json.loads(Path(a.input).read_text()))))