main
John Lauer Publish 0.1.26 0b71841
#!/usr/bin/env python3
"""Prefer board-edge side; optionally reserve a printed contact/name/value border.
Default contactBorders=true. The four border stroke rectangles participate in the
same collision solve as text. Full enclosure must contain no unrelated obstacles.
"""
import json,argparse,math
from pathlib import Path
from shapely.geometry import shape,Point,box,LineString
from shapely.ops import nearest_points,unary_union
from shapely.prepared import prep

def apply_policy(spec,geometry,enabled=True):
 outline=shape(geometry['outline']);stats={};bands={}
 # Shared rails and row heights are design constraints, not independent box fits.
 rows={}
 for g in spec['labels']:
  if g.get('orderGroup') and g['id'].startswith('MC') and g.get('candidates'):
   side=g['candidates'][0]['children'][0]['layer'];rows.setdefault((side,g['orderGroup']),[]).append(g)
 for (side,key),members in rows.items():
  if len(members)<3:continue
  xs=[g['anchor'][0] for g in members]
  if max(xs)-min(xs)>.1:continue
  ys=sorted(g['anchor'][1] for g in members);pitch=min(b-a for a,b in zip(ys,ys[1:]) if b-a>.1)
  targets=[shape(geometry['bodies'][side][g['id']]).bounds for g in members]
  if sum(xs)/len(xs)<outline.centroid.x:
   left=outline.bounds[0]+.12;right=max(b[2] for b in targets)+.10
  else:
   left=min(b[0] for b in targets)-.10;right=outline.bounds[2]-.12
  for g in members:bands[g['id']]=(left,right,pitch-.20,key)

 for group in spec['labels']:
  ref=group['id'];iscontact=ref.startswith(('MC','MP'))
  if not iscontact:continue
  anchor=Point(group['anchor']);edge=nearest_points(anchor,outline.exterior)[1];vx,vy=edge.x-anchor.x,edge.y-anchor.y
  side=group['candidates'][0]['children'][0]['layer'] if group['candidates'] else geometry.get('layer','F.SilkS')
  obs=[o for o in geometry['obstacles'] if o['layer']==side];target=shape(geometry['bodies'][side][ref]);fixed=prep(unary_union([shape(o['polygon']) for o in obs]));unrelated=prep(unary_union([shape(o['polygon']) for o in obs if o['reference']!=ref and o['kind'] not in ['native-silk-graphic','via-hole-and-mask']]))
  mask=unary_union([shape(o['polygon']).buffer(.04) for o in obs])
  kept=[];counts={'outside':0,'inside':0,'borderRejected':0}
  for c in group['candidates']:
   # Large primary penalty makes outside preferred ahead of font, within coverage.
   direction=(c['position'][0]-anchor.x)*vx+(c['position'][1]-anchor.y)*vy
   placementSide='outside' if direction>0 else 'inside';c['contactSide']=placementSide;c['qualityPenalty']=c.get('qualityPenalty',0)+(0 if placementSide=='outside' else 200)
   if enabled:
    bb=unary_union([target]+[box(*ch['box']) for ch in c['children']]).bounds
    region=box(bb[0]-.10,bb[1]-.10,bb[2]+.10,bb[3]+.10)
    if ref in bands and placementSide=='outside':
     left,right,height,key=bands[ref];shared=box(left,anchor.y-height/2,right,anchor.y+height/2)
     if not shared.buffer(-.06).covers(unary_union([target]+[box(*ch['box']) for ch in c['children']])):counts['borderRejected']+=1;continue
     region=shared;c['alignmentGroup']=key;c['sharedBorderBounds']=list(region.bounds)
    ring=region.exterior;stroke=ring.buffer(.03,join_style=2)
    if not outline.contains(stroke) or unrelated.intersects(region):counts['borderRejected']+=1;continue
    # Keep a visible grouping frame; make explicit safety gaps at nearby holes/artwork.
    safe=ring.difference(mask);segments=[safe] if safe.geom_type=='LineString' else list(safe.geoms) if safe.geom_type=='MultiLineString' else []
    segments=[line for line in segments if line.length>=.15]
    if not segments or sum(line.length for line in segments)<ring.length*.70:counts['borderRejected']+=1;continue
    points=list(ring.coords);paths=[list(line.coords) for line in segments]
    c['children'][0]['associationSegments']=paths;c['children'][0]['contactBorder']=True;c['contactBorder']=paths;c['borderStyle']='closed' if len(segments)==1 and segments[0].is_ring else 'interrupted-for-clearance'
    c['boxes'] += [list(LineString([a,b]).buffer(.03,cap_style=2).bounds) for line in paths for a,b in zip(line,line[1:])]
    c['box']=list(stroke.bounds)
   c['contactBorders']=enabled;kept.append(c);counts[placementSide]+=1
  group['candidates']=kept;stats[ref]=counts
 spec['contactBorders']=enabled;spec['contactPolicy']='nearest board edge first, coherent ordered rows, shared column border rails and row height, inside fallback';spec['contactPolicyStats']=stats
 return spec
if __name__=='__main__':
 p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--geometry',required=True);p.add_argument('--out',required=True);p.add_argument('--no-contact-borders',action='store_true');a=p.parse_args();r=apply_policy(json.loads(Path(a.input).read_text()),json.loads(Path(a.geometry).read_text()),not a.no_contact_borders);Path(a.out).write_text(json.dumps(r));print(json.dumps(r['contactPolicyStats']))