main
John Lauer Publish 0.2.15 bb17f84
#!/usr/bin/env python3
"""Bounded beam search for rectangular label candidates. EDA supplies conservative
visible-body/mask/text obstacles and measured text bounds; no native edits here.
Objective: place all labels, maximize font size, then minimize distance/motion.
Input/output mm. Every unresolved group remains explicit, never a silent omission.
"""
import argparse,json,math,time,uuid
EVENT_FILE=None
EVENT_ID=None
def emit(kind,**data):
 if EVENT_FILE:
  with open(EVENT_FILE,"a") as f:f.write(json.dumps(dict(kind=kind,at=time.time(),solveId=EVENT_ID,**data))+"\n")
from pathlib import Path

def overlap(a,b,gap=0):
 return not(a[2]+gap<=b[0] or b[2]+gap<=a[0] or a[3]+gap<=b[1] or b[3]+gap<=a[1])
def rects_of(x):
 # "box" | "boxes"; never dict.get('boxes',[x['box']]), whose default is evaluated eagerly.
 return x['boxes'] if 'boxes' in x else [x['box']]
def inside(a,b):return a[0]>=b[0] and a[1]>=b[1] and a[2]<=b[2] and a[3]<=b[3]
def solve_milp(spec,groups):
 import numpy as np
 from scipy.optimize import milp,Bounds,LinearConstraint
 from scipy.sparse import coo_matrix
 candidates=[];group_indices=[]
 for g,cs in groups:
  # Preserve alternatives at every font size; score truncation alone loses useful fallbacks.
  buckets={}
  for c in cs:buckets.setdefault(c['font'],[]).append(c)
  selected=[c for bucket in buckets.values() for c in bucket[:spec.get('milpPerFont',24)]]
  ix=[]
  for c in selected:ix.append(len(candidates));candidates.append((g,c))
  ix.append(len(candidates));candidates.append((g,dict(unresolved=True,score=-1e6)));group_indices.append(ix)
 rr=[];cc=[];vv=[];lo=[];hi=[]
 def row(indices,lower,upper):
  k=len(lo);rr.extend([k]*len(indices));cc.extend(indices);vv.extend([1.]*len(indices));lo.append(lower);hi.append(upper)
 for ix in group_indices:row(ix,1,1)
 gap=spec.get('clearance',.12)
 # Spatial broad phase keeps full-board grouped candidate sets practical.
 from shapely.geometry import box as geometry_box
 from shapely.strtree import STRtree
 valid=[i for i,(_,a) in enumerate(candidates) if not a.get('unresolved')]
 tree=STRtree([geometry_box(*candidates[i][1]['box']).buffer(gap) for i in valid])
 for i in valid:
  g,a=candidates[i]
  nearby={valid[int(k)] for k in tree.query(geometry_box(*a['box']))}
  if g.get('orderGroup'):
   nearby.update(j for j in valid if candidates[j][0].get('orderGroup')==g['orderGroup'])
  for j in nearby:
   if j>=i:continue
   h,b=candidates[j]
   if h['id']==g['id']:continue
   bad_order=g.get('orderGroup') and g.get('orderGroup')==h.get('orderGroup') and (g['order']-h['order'])*(a['position'][1]-b['position'][1])<=0
   conflict=bad_order or any(overlap(x,y,gap) for x in rects_of(a) for y in rects_of(b))
   if conflict:row([i,j],-np.inf,1)
 mat=coo_matrix((vv,(rr,cc)),shape=(len(lo),len(candidates))).tocsc();result=milp(c=-np.array([c['score'] for g,c in candidates]),integrality=np.ones(len(candidates)),bounds=Bounds(0,1),constraints=LinearConstraint(mat,lo,hi),options={'time_limit':spec.get('solverSeconds',45),'mip_rel_gap':.01})
 if result.x is None:raise ValueError('No candidate layout found within MILP budget: '+result.message)
 chosen=[dict(id=g['id'],orderGroup=g.get('orderGroup'),order=g.get('order'),**c) for x,(g,c) in zip(result.x,candidates) if x>.5]
 return dict(schemaVersion=1,placements=chosen,unresolved=[x['id'] for x in chosen if x.get('unresolved')],score=-float(result.fun),method='bounded mixed-integer candidate search; finite candidate set only',solverStatus=result.message,boundsSource=spec.get('boundsSource'),nativeVerified=False)
def solve(spec):
 emit('start',total=len(spec['labels']),nativeVerified=False)
 bounds=spec['bounds'];fixed=[r for x in spec.get('obstacles',[]) for r in rects_of(x)];gap=spec.get('clearance',.12);width=int(spec.get('beamWidth',96));groups=[]
 for g in spec['labels']:
  cs=[];sampled=0
  for c in g['candidates']:
   if g.get('requiredRoles'):
    roles={x.get('role'):x for x in c.get('children',[])}
    if any(role not in roles for role in g['requiredRoles']):continue
    if 'reference' in roles and 'value' in roles and roles['value']['size']>roles['reference']['size']*g.get('maxValueRatio',.8):continue
   if not all(math.isfinite(v) for v in c['box']) or c['box'][0]>=c['box'][2] or c['box'][1]>=c['box'][3]:raise ValueError('invalid bounds '+g['id'])
   if math.dist(c['position'],g['anchor'])>g.get('maxDistance',math.inf):continue
   rects=rects_of(c)
   reason='outside board' if not all(inside(a,bounds) for a in rects) else 'intersects a reserved obstacle' if any(overlap(a,b,gap) for a in rects for b in fixed) else 'clears fixed obstacles; competing labels still to check'
   if sampled<3:emit('candidate',id=g['id'],candidate=c,reason=reason);sampled+=1
   if reason.startswith('clears'):cs.append(c)
  # Prefer large legible text, locality next, and stability last. Caller can tune units.
  for c in cs:
   distance=math.dist(c['position'],g['anchor']);motion=math.dist(c['position'],g.get('previous',c['position']));c['score']=-c.get('qualityPenalty',0)+c['font']*spec.get('fontWeight',100)-distance*spec.get('distanceWeight',1)-motion*.1-(abs(c['position'][1]-g['anchor'][1])*spec.get('rowWeight',0) if g.get('orderGroup') else 0)-max(0,spec.get('preferredMinimumFont',0)-c['font'])*spec.get('smallFontPenalty',1000)
  cs.sort(key=lambda c:-c['score']);groups.append((g,cs[:spec.get('candidatesPerLabel',100)]))
 if spec.get('solver')=='milp':return solve_milp(spec,groups)
 # Most constrained first; beam alternatives allow earlier placements to be revised.
 groups.sort(key=lambda x:(len(x[1]),x[0]['id']));beam=[(0,[],[])]
 for g,cs in groups:
  next_states=[]
  for score,chosen,boxes in beam:
   for c in cs:
    if g.get('orderGroup') and any(not x.get('unresolved') and x.get('orderGroup')==g['orderGroup'] and (x['order']-g['order'])*(x['position'][1]-c['position'][1])<=0 for x in chosen):continue
    rects=rects_of(c)
    if any(overlap(a,b,gap) for a in rects for b in boxes):continue
    next_states.append((score+c['score'],chosen+[dict(id=g['id'],orderGroup=g.get('orderGroup'),order=g.get('order'),**c)],boxes+rects))
   next_states.append((score-1e6,chosen+[dict(id=g['id'],unresolved=True)],boxes))
  next_states.sort(key=lambda x:-x[0]);beam=next_states[:width]
 score,chosen,_=beam[0];return dict(schemaVersion=1,placements=chosen,unresolved=[x['id'] for x in chosen if x.get('unresolved')],score=score,method='bounded beam search; not a proof of global optimum',boundsSource=spec.get('boundsSource','caller supplied; native verification required'),nativeVerified=False)
if __name__=='__main__':
 p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--out',required=True);p.add_argument('--events');a=p.parse_args();EVENT_FILE=a.events;EVENT_ID=uuid.uuid4().hex;r=solve(json.loads(Path(a.input).read_text()));Path(a.out).write_text(json.dumps(r,indent=2));
 for item in r['placements']:emit('unresolved' if item.get('unresolved') else 'placement',id=item['id'],candidate=item,reason='No feasible candidate in the supplied search set' if item.get('unresolved') else 'Selected by font, locality and collision constraints; native review pending')
 emit('complete',unresolved=r['unresolved'],nativeVerified=False,method=r['method']);print('OK: silkscreen candidate layout; '+str(len(r['placements'])-len(r['unresolved']))+' placed, '+str(len(r['unresolved']))+' unresolved');print('Hint: Inspect fitted-body visibility, verify native text extents/DRC, and refresh BOTH native views before acceptance. This planner does not mutate the board.');raise SystemExit(2 if r['unresolved'] else 0)