main
Caleb Wording update 37a29f4 7d ago
import sys,json,math,uuid,pathlib,re
import numpy as np
from shapely.geometry import Point,box,LineString,Polygon
from shapely.ops import unary_union,polygonize,nearest_points
from shapely.affinity import rotate,translate
sys.path.insert(0,'/home/adom/aiflow-esc-astra');import copper_geometry as c
D=pathlib.Path(__file__).parent;src=D.parent/'components/quality-audit/completion/board/esc-g431-astra-components.kicad_pcb';root=c.parse(src)
edges=[c.arc(n) if n[0]=='gr_arc' else LineString([c.xy(n,'start'),c.xy(n,'end')]) for n in root if isinstance(n,list) and c.one(n,'layer')==['Edge.Cuts']];outline=max(polygonize(unary_union(edges)),key=lambda p:p.area).buffer(-.5)
obs={'F.SilkS':[],'B.SilkS':[]};parts=[]
for f in c.sub(root,'footprint'):
 p={x[1]:x[2] for x in c.sub(f,'property')};at=c.one(f,'at');x,y=map(float,at[:2]);ang=float(at[2]) if len(at)>2 else 0
 def world(g):return translate(rotate(g,-ang,origin=(0,0)),x,y)
 local=[];footpads=[]
 for n in f:
  if not isinstance(n,list):continue
  layer=c.one(n,'layer')
  if n[0] in ['fp_line','fp_rect'] and layer and layer[0] in ['F.Fab']:
   a=c.xy(n,'start');b=c.xy(n,'end');local.extend([a,b])
  if n[0]=='fp_circle' and layer and layer[0] in ['F.Fab']:
   a=c.xy(n,'center');b=c.xy(n,'end');rr=math.dist(a,b);local.extend([(a[0]-rr,a[1]-rr),(a[0]+rr,a[1]+rr)])
 for n in f:
  if isinstance(n,list) and n and c.one(n,'layer')==['F.SilkS'] and not (p['Reference'].startswith(('R','C')) and p['Reference'] not in ['C2','C3'] and n[0] in ['fp_line','fp_rect']):
   if n[0]=='fp_line':obs['F.SilkS'].append(world(LineString([c.xy(n,'start'),c.xy(n,'end')])).buffer(.12))
   elif n[0]=='fp_circle':
    center=c.xy(n,'center');rr=math.dist(center,c.xy(n,'end'));obs['F.SilkS'].append(world(Point(*center).buffer(rr).boundary).buffer(.12))
 for pad in c.sub(f,'pad'):
  px,py=map(float,c.one(pad,'at')[:2]);w,h=map(float,c.one(pad,'size'));a=c.one(pad,'at');pa=float(a[2]) if len(a)>2 else ang
  px,py=world(Point(px,py)).coords[0];g=translate(rotate(box(-w/2,-h/2,w/2,h/2),-pa,origin=(0,0)),px,py).buffer(.15)
  footpads.append(g)
  layers=c.one(pad,'layers')
  for side in obs:
   if side[0]+'.Mask' in layers or '*.Mask' in layers or pad[2]=='thru_hole':obs[side].append(g)
 if local:
  xx,yy=zip(*local);g=world(box(min(xx),min(yy),max(xx),max(yy))).buffer(.06);obs['F.SilkS'].append(g)
 else:g=Point(x,y).buffer(.35)
 g=unary_union([g]+footpads).convex_hull
 parts.append(dict(ref=p['Reference'],value=p.get('Value',''),x=x,y=y,geom=g,nets=[(a[1],c.net(a)) for a in c.sub(f,'pad')]))
# Existing visible text is reserved, not erased.
for f in c.sub(root,'footprint'):
 at=c.one(f,'at');x,y=map(float,at[:2]);ang=float(at[2]) if len(at)>2 else 0
 for n in c.sub(f,'property')+c.sub(f,'fp_text'):
  layer=c.one(n,'layer');hide=c.one(n,'hide')
  if not layer or layer[0] not in obs or hide==['yes'] or 'hide' in n:continue
  a=c.one(n,'at');px,py=map(float,a[:2]);t=n[2];size=c.one(c.one(c.one(n,'effects'),'font'),'size') or ['1','1'];sz=float(size[0]);g=translate(rotate(box(-len(t)*sz*.4,-sz*.7,len(t)*sz*.4,sz*.7),-ang,origin=(0,0)),x+px*math.cos(math.radians(ang))+py*math.sin(math.radians(ang)),y-px*math.sin(math.radians(ang))+py*math.cos(math.radians(ang)));obs[layer[0]].append(g)
fixed={s:unary_union(v) for s,v in obs.items()};placed={s:[] for s in obs};labels=[];missing=[];leaders=[]
def add(text,x,y,size,side='F.SilkS',source='',role='label'):
 # conservative whole-text envelope (native stroke-font DRC remains authoritative)
 w=max(.6,len(text)*size*.82);h=size*1.5;g=box(x-w/2,y-h/2,x+w/2,y+h/2)
 if not outline.contains(g) or fixed[side].intersects(g) or any(z.intersects(g.buffer(.12)) for z in placed[side]):return False
 placed[side].append(g);labels.append(dict(text=text,x=round(x,3),y=round(y,3),size=size,layer=side,source=source,role=role));return True
def clean(net):
 return {'/DRV_SHA':'PHASE A','/DRV_SHB':'PHASE B','/DRV_SHC':'PHASE C','/TIM15_CH1_DSHOT':'DSHOT','/USART1_TX':'UART TX','/USART1_RX':'UART RX'}.get(net,net.lstrip('/'))
def close_label(p,text,size,role,allow_leader=True):
 side='F.SilkS';lines=text.split('\n');w=max(.6,max(map(len,lines))*size*.82);h=size*(1.5*len(lines))
 xmin,ymin,xmax,ymax=p['geom'].bounds;candidates=[]
 for angle in [0,90]:
  if angle==90:w,h=h,w
  for gap in [.15,.3,.5,.8,1.2,1.7,2.3,3.0]:
   for offset in [0,-.4,.4,-.8,.8,-1.2,1.2]:
    for xx,yy in [(p['x']+offset,ymin-h/2-gap),(p['x']+offset,ymax+h/2+gap),(xmin-w/2-gap,p['y']+offset),(xmax+w/2+gap,p['y']+offset)]:
     g=box(xx-w/2,yy-h/2,xx+w/2,yy+h/2)
     if not outline.contains(g) or fixed[side].intersects(g) or any(z.intersects(g.buffer(.12)) for z in placed[side]):continue
     d=g.distance(p['geom']);others=c.shapely.distance(g,np.array([q['geom'] for q in parts if q['ref']!=p['ref']],dtype=object))
     # A nearby label must be closer to its own part; otherwise it needs an explicit leader.
     need=d>.55 or (len(others)>0 and min(others)<d-.08)
     line=None
     if need:
      if not allow_leader:continue
      aa,bb=nearest_points(g,p['geom']);v=LineString([aa,bb]);length=v.length
      if length<.30:continue
      line=LineString([v.interpolate(.12),v.interpolate(length-.15)])
      if line.buffer(.065).intersects(fixed[side]) or any(line.buffer(.15).intersects(z) for z in placed[side]):continue
     candidates.append((need,d+math.hypot(xx-p['x'],yy-p['y'])*.05,xx,yy,g,line,angle))
 if not candidates:return False
 _,d,xx,yy,g,line,angle=min(candidates,key=lambda v:(v[0],v[1]));placed[side].append(g)
 item=dict(text=text,x=round(xx,3),y=round(yy,3),size=size,layer=side,source=p['ref']+' actual board properties/pad nets',role=role,reference=p['ref'],angle=angle,bodyGapMm=round(g.distance(p['geom']),3),leader=line is not None)
 labels.append(item)
 if line is not None:
  leaders.append(dict(reference=p['ref'],start=list(line.coords[0]),end=list(line.coords[-1])));placed[side].append(line.buffer(.12))
 return True
# Repeat the numbered J1 pinout on the front's free service-label area.
add('J1 PINOUT',157,68,.6,source='J1 actual pad nets',role='interface key')
j=next(p for p in parts if p['ref']=='J1')
for ii,(pin,net) in enumerate(j['nets']):
 label=pin+' '+('NC' if net.startswith('unconnected') else clean(net).replace('UART ','U'))
 add(label,153.2+(ii%2)*7.5,69.2+(ii//2)*.85,.4,source='J1 pad '+pin+' actual net',role='interface key')
# Reference association takes precedence over values and decorative text.
for p in sorted(parts,key=lambda p:(0 if p['ref'].startswith('TP') else 1 if p['ref'].startswith(('MC','MP','J')) else 2,p['geom'].area,p['ref'])):
 r=p['ref'];v=p['value'];text=r
 if r.startswith(('MC','MP')):text=r+'\n'+clean(p['nets'][0][1])
 elif r.startswith('TP'):text=r+'\n'+v.replace('PHASE ','PH ').replace('UART_','U')
 elif r=='J1':text='J1\nSTDC14'
 elif r=='SW1':text='SW1\nRESET'
 elif r.startswith('LED'):text=r+'\n'+v
 ok=False
 for size in [.6,.5,.4,.3,.25]:
  if close_label(p,text,size,'near reference/function'):
   ok=True;break
 if not ok:missing.append(dict(ref=r,text=text,reason='no unambiguous nearby label or clear short leader'))
# Values only where an adjacent association remains clear after ALL references are placed.
for p in parts:
 r=p['ref'];v=p['value']
 if not r.startswith(('R','C')):continue
 own=next((x for x in labels if x.get('reference')==r),None)
 if not own or own.get('angle',0):continue
 value=v+('F' if r.startswith('C') and v[-1:] in ['u','n','p'] else '')
 # The key on the back provides values when there is no immediately adjacent space.
 for sz in [.5,.3,.2]:
  value_placed=False
  for dy in [own['size']*.75+sz*.75+.22,-own['size']*.75-sz*.75-.22]:
   x,y=own['x'],own['y']+dy;g=box(x-len(value)*sz*.82/2,y-sz*.75,x+len(value)*sz*.82/2,y+sz*.75)
   if g.distance(p['geom'])>1.8:continue
   if add(value,x,y,sz,source=r+' actual board value',role='value paired to reference'):
    value_placed=True;break
  if value_placed:break
add('ESC-G431',157,69,1.1,source='project identity',role='identity')
add('MOTOR CONTROL',157,71,.65,source='project identity',role='identity')
add('ASTRA-S1',157,72.4,.65,source='silkscreen revision',role='revision')
# Back-side manual. At y~70..120 the only obstacles are sparse through-holes.
S='B.SilkS'
def back(text,x,y,size=.65,role='service-key'):return add(text,x,y,size,S,'actual board properties and numbered pad nets',role)
back('ADOM  ESC-G431',136,67,1.5,'identity')
back('3-PHASE MOTOR CONTROLLER',136,69.4,.85)
back('ASTRA-S1  |  SERVICE REFERENCE',136,71.2,.7)
back('wiki.adom.inc/adom/esc-g431',136,73,.65)
back('POWER: MC1 +VBAT  |  MC3 GND_OUT',136,76,.75)
back('MOTOR: MC5 A   MC9 B   MC11 C',136,77.7,.75)
back('CHECK POLARITY BEFORE APPLYING POWER',136,79.4,.65)
back('Operating limits: see approved project specification',136,81,.5)
# machine connector key and STDC14, columns short to keep exact pad mapping
back('MACHINE CONTACTS',119,84,.8);back('J1 STDC14 / SWD',151,84,.8)
contacts=[p for p in parts if p['ref'].startswith('MC')];contacts.sort(key=lambda p:int(p['ref'][2:]))
for i,p in enumerate(contacts):
 col=i//12;row=i%12;back(p['ref']+' '+clean(p['nets'][0][1]),114+col*18,86+row*1.25,.5)
j=next(p for p in parts if p['ref']=='J1')
for i,(pin,net) in enumerate(j['nets']):back(pin+' '+('NC' if net.startswith('unconnected') else clean(net)),153,86+i*1.12,.55)
back('MC4/6/7/13/14/18/19/22/23: GND',124,102,.5)
back('MP1 / MP2 / MP3 / MP4: GND',124,103.4,.55)
back('SW1 RESET  |  JP1 GND / 5V_EN',140,105,.6)
back('LED1/2 GREEN  |  LED3 BLUE',136,127,.5)
back('COMPONENT VALUES',136,107,.8)
# compact value key retains all exact ref/value pairs in grouped lines
for prefix,x,count in [('R',115,34),('C',135,45)]:
 values=[p for p in parts if re.fullmatch(prefix+r'\d+',p['ref'])];values.sort(key=lambda p:int(p['ref'][1:]))
 for i in range(0,len(values),3):
  text='  '.join(p['ref']+' '+p['value'] for p in values[i:i+3]);back(text,x,108.7+(i//3)*1.05,.5)
for i,p in enumerate(sorted([p for p in parts if p['ref'].startswith(('U','Q','D','Y','FB'))],key=lambda p:p['ref'])):
 back(p['ref']+' '+p['value'],156,108+i*1.07,.5)
back('Values are nominal; ratings and tolerances are in the BOM.',136,125.8,.5)
back('Power off before wiring. Use a current-limited first power-up.',136,130,.5)
# Append graphics only: original file and every original S-expression remain untouched.
raw=src.read_text();new=[]
# Remove only redundant silk outlines from nonpolar small R/C footprints.
# Keep all source bytes outside those exact balanced-expression spans.
def spans(text):
 stack=[];quoted=False;escaped=False;out=[]
 for i,ch in enumerate(text):
  if quoted:
   if escaped:escaped=False
   elif ch=='\\':escaped=True
   elif ch=='"':quoted=False
  elif ch=='"':quoted=True
  elif ch=='(':stack.append(i)
  elif ch==')':
   a=stack.pop();out.append((a,i+1,len(stack)))
 return out
cuts=[]
for a,b,depth in spans(raw):
 if depth!=1 or not raw[a:b].startswith('(footprint '):continue
 block=raw[a:b];m=re.search(r'\(property "Reference" "([^"]+)"',block)
 if not m or not m[1].startswith(('R','C')) or m[1] in ['C2','C3']:continue
 for aa,bb,dd in spans(block):
  node=block[aa:bb]
  if dd==1 and node.startswith(('(fp_line','(fp_rect')) and '(layer "F.SilkS")' in node:cuts.append((a+aa,a+bb))
for a,b in sorted(cuts,reverse=True):raw=raw[:a]+raw[b:]

for l in labels:
 mirror=' (justify mirror)' if l['layer']=='B.SilkS' else ''
 new.append(f'(gr_text {json.dumps(l["text"])} (at {l["x"]} {l["y"]} {l.get("angle",0)}) (layer "{l["layer"]}") (uuid "{uuid.uuid5(uuid.NAMESPACE_URL,json.dumps(l,sort_keys=True))}") (effects (font (size {l["size"]} {l["size"]}) (thickness {0.10 if l["size"]>=.8 else min(.08,l["size"]*.15)})){mirror}))')
for line in leaders:
 new.append(f'(gr_line (start {line["start"][0]:.4f} {line["start"][1]:.4f}) (end {line["end"][0]:.4f} {line["end"][1]:.4f}) (stroke (width 0.10) (type default)) (layer "F.SilkS") (uuid "{uuid.uuid5(uuid.NAMESPACE_URL,json.dumps(line,sort_keys=True))}"))')
out=D/'esc-g431-astra-silkscreen-v11.kicad_pcb' ;out.write_text(raw.rstrip()[:-1]+'\n'+'\n'.join(new)+'\n)\n');parsed=c.parse(out)
def strip_silk(n):
 if not isinstance(n,list):return n
 if n and n[0] in ['gr_text','gr_line','fp_line','fp_rect'] and c.one(n,'layer') in [['F.SilkS'],['B.SilkS']]:return None
 return [v for x in n if (v:=strip_silk(x)) is not None]
assert strip_silk(parsed)==strip_silk(root)
(D/'labels.json').write_text(json.dumps({'board':str(out),'source':str(src),'nonSilkscreenDataUnchanged':True,'removedRedundantPassiveSilkStrokes':len(cuts),'labels':labels,'leaders':leaders,'omittedNearby':missing,'notes':['0.5mm text is the requested 3rd party fab target; physical coupon not tested.','No unverified voltage/current ratings printed.','Conservative geometry placement pending native DRC and render.']},indent=2))

print(len(labels), 'labels', len(missing),'nearby deferred')