main
John Lauer Hero GLB per eda-component-hero: footprint pad outlines, silkscreen and pad numbers as nonphysical layers; plain STEP and board GLB unchanged; provenance a0f3a29 5d ago
#!/usr/bin/env python3
"""Add nonphysical footprint reference artwork to a Z-up component GLB.
Original geometry buffers and source files are preserved. Units: GLB metres,
KiCad footprint millimetres with Y down. Glyph outlines use a supplied TrueType font.
"""
import argparse,json,re,math,struct,hashlib
from pathlib import Path
from fontTools.ttLib import TTFont
from fontTools.pens.basePen import BasePen
class Atom(str):pass

def parse(s):
 stack=[];root=None
 for t in re.findall(r'"(?:\\.|[^"\\])*"|[()]|[^\s()]+',s):
  if t=='(':
   a=[]
   if stack:stack[-1].append(a)
   else:root=a
   stack.append(a)
  elif t==')':stack.pop()
  else:stack[-1].append(json.loads(t) if t.startswith('"') else Atom(t))
 assert not stack
 return root
def dump(n):
 return '('+' '.join(map(dump,n))+')' if isinstance(n,list) else str(n) if isinstance(n,Atom) else json.dumps(n,ensure_ascii=False)
def fields(n,key):return [x for x in n if isinstance(x,list) and x and x[0]==key]
def field(n,key,default=None):return next(iter(fields(n,key)),default)
def xy(n,key):return tuple(map(float,field(n,key)[1:3]))
def circle(c,r,start=0,span=2*math.pi,count=48):return [(c[0]+r*math.cos(start+span*i/count),c[1]+r*math.sin(start+span*i/count)) for i in range(count+1)]
def rounded(w,h,r):
 out=[]
 for cx,cy,a in [(w/2-r,h/2-r,0),(-w/2+r,h/2-r,math.pi/2),(-w/2+r,-h/2+r,math.pi),(w/2-r,-h/2+r,math.pi*1.5)]:out+=circle((cx,cy),r,a,math.pi/2,8)
 return out+[out[0]]
def dashed(points,on=.07,off=.045):
 out=[];distance=0
 for a,b in zip(points,points[1:]):
  length=math.dist(a,b);pos=0
  while pos<length-1e-12:
   phase=distance%(on+off);take=min(length-pos,(on-phase) if phase<on else (on+off-phase))
   if take<1e-10:distance+=1e-9;continue
   if phase<on:
    out.append([(a[0]+(b[0]-a[0])*u/length,a[1]+(b[1]-a[1])*u/length) for u in [pos,pos+take]])
   distance+=take;pos+=take
 return out
class Flatten(BasePen):
 def __init__(self,g):super().__init__(g);self.paths=[];self.path=[]
 def _moveTo(self,p):self.path=[p]
 def _lineTo(self,p):self.path.append(p)
 def _curveToOne(self,a,b,c):
  p=self._getCurrentPoint()
  for i in range(1,9):
   t=i/8;self.path.append(tuple((1-t)**3*p[j]+3*(1-t)**2*t*a[j]+3*(1-t)*t*t*b[j]+t**3*c[j] for j in range(2)))
 def _qCurveToOne(self,a,b):
  p=self._getCurrentPoint()
  for i in range(1,9):
   t=i/8;self.path.append(tuple((1-t)**2*p[j]+2*(1-t)*t*a[j]+t*t*b[j] for j in range(2)))
 def _closePath(self):self.path.append(self.path[0]);self.paths.append(self.path);self.path=[]
 def _endPath(self):self.paths.append(self.path);self.path=[]
def main():
 ap=argparse.ArgumentParser();ap.add_argument('--glb',type=Path,required=True);ap.add_argument('--footprint',type=Path,required=True);ap.add_argument('--library',type=Path,required=True);ap.add_argument('--font',type=Path,required=True);ap.add_argument('--out',type=Path,required=True);ap.add_argument('--label-height',type=float,default=.17,help='pad label glyph height in mm (default .17, sized for fine-pitch ICs)');args=ap.parse_args()
 fp=parse(args.footprint.read_text());library=json.loads(args.library.read_text());pin_names={p['number']:p['name'] for p in library['symbol']['pins']};pad_names={}
 for c in library['connect']:
  name=pin_names[c['pin']]
  if c['pad'] in pad_names:assert pad_names[c['pad']]==name,'Ambiguous pad name mapping'
  pad_names[c['pad']]=name
 font=TTFont(args.font);glyphs=font.getGlyphSet();cmap=font.getBestCmap();em=font['head'].unitsPerEm
 def textpaths(text,at,height):
  paths=[];cursor=0
  for ch in text:
   name=cmap.get(ord(ch));assert name,repr(ch)
   pen=Flatten(glyphs);glyphs[name].draw(pen);paths.extend([[(x+cursor,y) for x,y in line] for line in pen.paths]);cursor+=glyphs[name].width
  scale=height/(em*.75)
  return [[(at[0]+(x-cursor/2)*scale,at[1]+(y-em*.35)*scale) for x,y in line] for line in paths]
 pads=[];padlines=[];silk=[];labels=[]
 for p in fields(fp,'pad'):
  if not any(x in field(p,'layers')[1:] for x in ['F.Cu','*.Cu']):continue
  number=p[1];at=xy(p,'at');size=xy(p,'size');angle=math.radians(-float(field(p,'at')[3]) if len(field(p,'at'))>3 else 0);shape=p[3]
  if shape=='roundrect':r=min(size)*float(field(p,'roundrect_rratio')[1]);outline=rounded(*size,r)
  elif shape=='rect':outline=rounded(*size,0)
  elif shape=='circle':outline=circle((0,0),size[0]/2)
  elif shape=='oval':outline=rounded(*size,min(size)/2)
  elif shape=='custom':
   from shapely.geometry import Polygon,Point,LineString,box
   from shapely.ops import unary_union
   shapes=[]
   anchor=field(field(p,'options',[]),'anchor',['anchor','rect'])[1]
   shapes.append(Point(0,0).buffer(size[0]/2) if anchor=='circle' else box(-size[0]/2,-size[1]/2,size[0]/2,size[1]/2))
   for pr in field(p,'primitives',[])[1:]:
    width=float(field(pr,'width',['width',0])[1])
    if pr[0]=='gr_poly':
     pts=[tuple(map(float,q[1:3])) for q in field(pr,'pts')[1:]];shapes.append(Polygon(pts).buffer(width/2))
    elif pr[0]=='gr_line':shapes.append(LineString([xy(pr,'start'),xy(pr,'end')]).buffer(width/2))
    elif pr[0]=='gr_circle':shapes.append(Point(*xy(pr,'center')).buffer(math.dist(xy(pr,'center'),xy(pr,'end'))+width/2))
    else:raise ValueError('Unsupported custom copper primitive '+pr[0])
   merged=unary_union(shapes);assert merged.geom_type=='Polygon','Disconnected custom copper pad requires explicit review';outline=list(merged.exterior.coords)
  else:raise ValueError('Unsupported pad shape '+shape)
  def transform(q):return (at[0]+q[0]*math.cos(angle)-q[1]*math.sin(angle),-(at[1]+q[0]*math.sin(angle)+q[1]*math.cos(angle)))
  outline=list(map(transform,outline));padlines+=dashed(outline)
  drill=field(p,'drill',[])
  if drill:
   dims=[float(v) for v in drill[1:] if not isinstance(v,list) and v!='oval'];off=field(drill,'offset',['offset',0,0]);center=(float(off[1]),float(off[2]))
   hole=rounded(dims[0],dims[1],min(dims)/2) if drill[1]=='oval' else circle((0,0),dims[0]/2)
   hole=[transform((x+center[0],y+center[1])) for x,y in hole];padlines+=dashed(hole)
  bounds=([min(q[i] for q in outline) for i in range(2)],[max(q[i] for q in outline) for i in range(2)])
  raw_name=pad_names.get(number,number) or number;
  if not raw_name:raise ValueError('No electrical mapping for copper pad '+number)
  active_low=bool(re.fullmatch(r'~\{[^}]+\}',raw_name));display_name=raw_name[2:-1] if active_low else raw_name
  unnamed_passive=len(library['symbol']['pins'])==2 and all(q['electrical_type']=='passive' for q in library['symbol']['pins']) and raw_name in ['', '~', number]
  if unnamed_passive:display_name=number
  if not display_name:raise ValueError('Missing signal name for pad '+number)
  glyphs_local=textpaths(display_name,(0,0),args.label_height);points=[q for line in glyphs_local for q in line];xlo=min(q[0] for q in points);xhi=max(q[0] for q in points)
  if active_low:
   bar_y=max(q[1] for q in points)+.025;glyphs_local.append([(xlo,bar_y),(xhi,bar_y)])
  padcenters=[xy(q,'at') for q in fields(fp,'pad') if any(v in field(q,'layers')[1:] for v in ['F.Cu','*.Cu'])]
  maxx=max(abs(v[0]) for v in padcenters) or 1;maxy=max(abs(v[1]) for v in padcenters) or 1
  vertical=abs(at[1])/maxy > abs(at[0])/maxx+.05
  if vertical:
   # Radial labels outside top/bottom rows, with text along the outward axis.
   glyphs_local=[[(-y,x) for x,y in line] for line in glyphs_local];points=[q for line in glyphs_local for q in line];xlo=min(q[0] for q in points);xhi=max(q[0] for q in points)
   dx=at[0];label_y=(bounds[1][1]+.32-min(q[1] for q in points)) if at[1]<0 else bounds[0][1]-.32-max(q[1] for q in points)
  else:
   dx=(bounds[0][0]-.32-xhi) if at[0]<0 else (bounds[1][0]+.32-xlo);label_y=-at[1]
  if abs(at[0])<.01 and abs(at[1])<.01:
   ys=[-xy(q,'at')[1]+sign*xy(q,'size')[1]/2 for q in fields(fp,'pad') for sign in [-1,1]];dx=0;label_y=min(ys)-.65
  labelat=(dx,label_y);labels += [[(x+dx,y+label_y) for x,y in line] for line in glyphs_local]
  pads.append({'number':number,'drill_boundary_drawn':bool(drill),'signal_name':raw_name,'display_name':display_name,'pad_number_fallback':unnamed_passive,'active_low_overbar':active_low,'center_mm':[at[0],-at[1]],'copper_size_mm':size,'copper_outline_bounds_mm':bounds,'label_position_mm':labelat})
 for p in fp:
  if not isinstance(p,list) or field(p,'layer')!=['layer','F.SilkS']:continue
  kind=p[0]
  if kind=='fp_line':silk.append([(x,-y) for x,y in [xy(p,'start'),xy(p,'end')]])
  elif kind=='fp_circle':
   c=xy(p,'center');e=xy(p,'end');silk.append([(x,-y) for x,y in circle(c,math.dist(c,e))])
  elif kind=='fp_rect':
   a,b=xy(p,'start'),xy(p,'end');silk.append([(x,-y) for x,y in [a,(b[0],a[1]),b,(a[0],b[1]),a]])
  elif kind=='fp_poly':
   points=[tuple(map(float,q[1:3])) for q in field(p,'pts')[1:]];silk.append([(x,-y) for x,y in points+[points[0]]])
  elif kind=='fp_arc':
   import numpy as np
   a,m,z=[np.array(xy(p,k)) for k in ['start','mid','end']];center=np.linalg.solve(2*np.array([m-a,z-a]),np.array([m@m-a@a,z@z-a@a]));rad=float(np.linalg.norm(a-center));t0,t1,t2=[math.atan2(*(v-center)[::-1]) for v in [a,m,z]];span=(t2-t0)%(2*math.pi)
   if (t1-t0)%(2*math.pi)>span:span-=2*math.pi
   silk.append([(x,-y) for x,y in circle(center,rad,t0,span,48)])
  elif kind=='property':
   # Identity placeholders/hidden properties are not physical footprint silk.
   if field(p,'hide') or 'hide' in p or p[1]=='Reference' or p[2] in ['REF**','REF*','${REFERENCE}']:continue
   at=xy(p,'at');effects=field(p,'effects');height=float(field(field(effects,'font'),'size')[2]);silk+=textpaths(p[2],(at[0],-at[1]),height)
  elif kind=='fp_text':
   if 'hide' in p or (p[1]=='reference' and p[2] in ['REF**','REF*','${REFERENCE}']):continue
   at=xy(p,'at');effects=field(p,'effects');height=float(field(field(effects,'font'),'size')[2]);silk+=textpaths(p[2],(at[0],-at[1]),height)
  else:raise ValueError('Unsupported silk primitive '+kind)
 b=args.glb.read_bytes();jn,jt=struct.unpack_from('<II',b,12);d=json.loads(b[20:20+jn]);bn,bt=struct.unpack_from('<II',b,20+jn);assert bt==0x004e4942
 original=b[28+jn:28+jn+bn];binary=bytearray(original);assert len(d['buffers'])==1 and 'uri' not in d['buffers'][0]
 ext=d.setdefault('extensionsUsed',[])
 if 'KHR_materials_unlit' not in ext:ext.append('KHR_materials_unlit')
 parent={'name':'Adom reference artwork (nonphysical)','children':[],'extras':{'physical':False,'purpose':'footprint copper outlines, silkscreen and pad identifiers','sourceFootprint':args.footprint.name,'units':'metre','upAxis':'Z'}}
 parentid=len(d['nodes']);d['nodes'].append(parent)
 roots=d['scenes'][d.get('scene',0)]['nodes'];poses=[i for i in roots if d['nodes'][i].get('name','').startswith('Adom.Hero.')]
 if len(poses)==1:d['nodes'][poses[0]].setdefault('children',[]).append(parentid)
 elif not poses:roots.append(parentid)
 else:raise ValueError('Ambiguous hero presentation transform')
 def add(name,paths,color,alpha,width):
  vertices=[];z=-.00002
  for path in paths:
   for a,c in zip(path,path[1:]):
    length=math.dist(a,c)
    if length<1e-10:continue
    nx=-(c[1]-a[1])/length*width/2;ny=(c[0]-a[0])/length*width/2
    quad=[(a[0]+nx,a[1]+ny),(a[0]-nx,a[1]-ny),(c[0]-nx,c[1]-ny),(c[0]+nx,c[1]+ny)]
    for i in [0,1,2,0,2,3]:vertices.append((quad[i][0]/1000,quad[i][1]/1000,z))
  if not vertices:return
  binary.extend(b'\0'*((-len(binary))%4));offset=len(binary);raw=b''.join(struct.pack('<fff',*v) for v in vertices);binary.extend(raw)
  bv=len(d['bufferViews']);d['bufferViews'].append({'buffer':0,'byteOffset':offset,'byteLength':len(raw),'target':34962})
  ac=len(d['accessors']);d['accessors'].append({'bufferView':bv,'componentType':5126,'count':len(vertices),'type':'VEC3','min':[min(v[i] for v in vertices) for i in range(3)],'max':[max(v[i] for v in vertices) for i in range(3)]})
  material=len(d['materials']);d['materials'].append({'name':name,'pbrMetallicRoughness':{'baseColorFactor':color+[alpha],'metallicFactor':0,'roughnessFactor':1},'alphaMode':'BLEND' if alpha<1 else 'OPAQUE','doubleSided':True,'extensions':{'KHR_materials_unlit':{}},'extras':{'referenceArtwork':True,'opacity':alpha}})
  mesh=len(d['meshes']);d['meshes'].append({'name':name,'primitives':[{'attributes':{'POSITION':ac},'material':material,'mode':4}]})
  node=len(d['nodes']);d['nodes'].append({'name':name,'mesh':mesh,'extras':{'physical':False}});parent['children'].append(node)
 add('Footprint pads - teal dashed outlines - 50 percent opacity',padlines,[0,.902,.863],.5,.015)
 add('Silkscreen reference lines - 30 percent opacity',silk,[.92,.96,1],.3,.012)
 add('Signal names - 50 percent opacity',labels,[.92,.96,1],.5,.009)
 d['buffers'][0]['byteLength']=len(binary);assert binary[:len(original)]==original
 j=json.dumps(d,separators=(',',':')).encode();j+=b' '*((-len(j))%4);binary.extend(b'\0'*((-len(binary))%4))
 out=struct.pack('<III',0x46546c67,2,28+len(j)+len(binary))+struct.pack('<II',len(j),0x4e4f534a)+j+struct.pack('<II',len(binary),0x004e4942)+binary;args.out.write_bytes(out)
 report={'font_file':args.font.name,'font_sha256':hashlib.sha256(args.font.read_bytes()).hexdigest(),'generator_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),'source_glb_sha256':hashlib.sha256(b).hexdigest(),'footprint_sha256':hashlib.sha256(args.footprint.read_bytes()).hexdigest(),'derivative_sha256':hashlib.sha256(out).hexdigest(),'manufacturer_geometry_buffer_prefix_unchanged':True,'units':'metre','upAxis':'Z','plane_z_mm':-.02,'pad_outline':'copper perimeter, not solder mask or paste','dash_mm':.07,'gap_mm':.045,'silkscreen_path_count':len(silk),'silkscreen_opacity':.3,'pad_outline_opacity':.5,'pad_outline_color':'teal #00E6DC','uninstantiated_reference_placeholder':'omitted from component display','pad_name_opacity':.5,'pad_label_height_mm':args.label_height,'pad_names':'signal names resolved through canonical pin-to-pad mapping; active-low overbars preserved','library_sha256':hashlib.sha256(args.library.read_bytes()).hexdigest(),'pads':pads,'overlay_nodes':len(parent['children']),'limitations':['Nonphysical reference lines, not a manufacturing layer or manufacturer STEP geometry.','Silkscreen text follows source text/placement but uses the supplied display font.','Physical body can occlude artwork on the seating plane; inspect underside or hide the body.']}
 args.out.with_suffix('.overlay.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report))
if __name__=='__main__':main()