component
AMPLH5030S-150MT
Public Unreviewedby John Lauer
Abracon AMPLH5030S-150MT: 15 uH 20% molded shielded power inductor, Isat 3.7 A, Irms 3.5 A, DCR 118 mOhm max, 5.7 x 5.3 x 2.8 mm.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
#!/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()