AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
Add live silkscreen search dashboard, replay clips and whole-board obstacle audits #17
Fable — add an optional, observable silkscreen search to AI Flow, with a shared live/replay dashboard and a fail-closed whole-board obstacle inventory audit.
The motivating ESC defect was text beneath capacitor bodies and over vias: missing F.Fab drawings had been treated as empty body space, and a J1-local via search left other labels unchecked. The guidance now requires actual transformed fitted-body envelopes, every via/hole on both faces, complete coverage counts, native text bounds, reference/value association and separate native 2D/3D acceptance. DRC alone cannot establish fitted visibility.
This is based on a fresh wiki source clone, not a wholesale copy of the older component-workflow branch. New commands are silkscreen-layout [--events ...], silkscreen-audit, and silkscreen-dashboard start|show|ls|state|control|stop; every invocation retains the usual run/thread flags and ledger handling. The installer links the dashboard helper. flows/board.json, both skill entrypoints and docs describe the stage and opt-in observer.
The Python solver emits real timestamped start, bounded candidate samples, selected proposals, unresolved and completion events. No provider calls, no intentional solver delays, and no invented internal reasoning. The dashboard uses SSE, persisted settings/per-thread snapshots, discoverable/reused instances, idle cleanup, identity checks, live/replay badges, scrubbing, top/bottom 2D, and fitted-component GLBs in a clearly identified simplified 3D board preview. Native editing remains bridge-owned; see KiCad Bridge #104 and #105. Fusion/Altium adapters are NOT claimed implemented. The ESC manifest/export adapter remains task-specific; universal automatic board ingestion is not claimed.
Two recording controls produce a detailed replay and a ~5s overview. Both are explicitly replays, not native KiCad footage. Browser WebM exports should be normalized with ffmpeg fps=30 before composition (the WebM nominal rate may be 1000 despite variable-rate captured frames). Raw native per-step clips remain untouched.
Validation: release Rust build against the fresh clone; real CLI audit and dashboard dispatch; planner beam/MILP regression tests; missing body/via-face/text-extent audit tests; unresolved MILP/event tests; API identity and invalid-control refusals; live Hydrogen webview rendered and inspected; 128 fitted models loaded, 444 vias represented; compatible mesh merging reduced ~47k meshes to <700; replay exports decoded successfully. The overview is 4.52s, the detailed replay 76.50s. Fixed bugs found during testing: unresolved MILP duplicate IDs, replay pacing drift, empty-recording race, server restart socket reuse, overexposed StandardMaterial board slab.
ESC acceptance is deliberately unfinished: 76/79 resistor/capacitor groups placed, C9/C32/C36 unresolved; native KiCad still shows v28. This PR does not claim the ESC silkscreen is corrected or DRC/visibility-qualified. The dashboard displays these limits.
Related earlier PR #16 contains broader silkscreen/component work. Please integrate overlapping silk guidance once; this focused PR preserves the current source and does not replace the pending component-library/hero work. Source handoff is not a registry release: please build/publish an insiders package and reply with the version for deployment verification.
Development evidence: https://wiki.adom.inc/adom/esc-g431/files/docs/astra/silkscreen-dashboard/README.md
Diff Skip to comments (1)
@@ -0,0 +1,82 @@+#!/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 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)+ for i,(g,a) in enumerate(candidates):+ if a.get('unresolved'):continue+ for j in range(i):+ h,b=candidates[j]+ if h['id']==g['id'] or b.get('unresolved'):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 a.get('boxes',[a['box']]) for y in b.get('boxes',[b['box']]))+ 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=[x['box'] for x in spec.get('obstacles',[])];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 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=c.get('boxes',[c['box']])+ 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['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=c.get('boxes',[c['box']])+ 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)+@@ -0,0 +1,66 @@+#!/usr/bin/env python3+"""Optional bounded leader candidate routing for the rectangle planner.+Caller supplies per-label leaderTargets and routeBounds in board millimetres.+Routes are candidate geometry, requiring native clearance and visual review.+"""+import argparse,json,math,heapq+from pathlib import Path++def prepare(spec):+ gap=spec.get('clearance',.15);step=spec.get('leaderGrid',.1);fixed=[o['box'] for o in spec['obstacles']]+ def hit(a,b,g=0):return not(a[2]+g<=b[0] or b[2]+g<=a[0] or a[3]+g<=b[1] or b[3]+g<=a[1])+ for group in spec['labels']:+ if not group.get('leaderTargets'):continue+ rb=group['routeBounds'];nx=round((rb[2]-rb[0])/step)+1;ny=round((rb[3]-rb[1])/step)+1+ def point(n):return rb[0]+n[0]*step,rb[1]+n[1]*step+ def cell(p):return round((p[0]-rb[0])/step),round((p[1]-rb[1])/step)+ blocked=set()+ def occupy(b):+ x0=max(0,math.ceil((b[0]-gap-.03-rb[0])/step));x1=min(nx-1,math.floor((b[2]+gap+.03-rb[0])/step));y0=max(0,math.ceil((b[1]-gap-.03-rb[1])/step));y1=min(ny-1,math.floor((b[3]+gap+.03-rb[1])/step))+ return {(x,y) for x in range(x0,x1+1) for y in range(y0,y1+1)}+ for b in fixed:blocked.update(occupy(b))+ target_list=[cell(p) for p in group['leaderTargets']];target_list=[n for n in target_list if n not in blocked];targets=set(target_list[:1]);accepted=[]+ if not targets:group['candidates']=[];continue+ candidates=[c for c in group['candidates'] if not any(hit(c['box'],b,gap) for b in fixed)]+ candidates.sort(key=lambda c:-(c['font']*spec.get('fontWeight',70)-math.dist(c['position'],group['anchor'])*spec.get('distanceWeight',7)))+ bands={}+ for c in candidates:bands.setdefault(c['font'],[]).append(c)+ candidates=[c for band in bands.values() for c in band[:spec.get('leaderAttemptsPerFont',180)]]+ for c in candidates:+ if sum(q['font']==c['font'] for q in accepted)>=spec.get('leaderCandidatesPerFont',40):continue+ if c.get('angle',0)==0 and abs(c['position'][1]-group['anchor'][1])<=.3 and math.dist(c['position'],group['anchor'])<3.5:accepted.append(c);continue+ b=c['box'];occupied=blocked|occupy(b)+ def free(n):return 0<=n[0]<nx and 0<=n[1]<ny and n not in occupied+ starts=set();pad=gap+.08+ for x in range(math.floor((b[0]-pad-rb[0])/step),math.ceil((b[2]+pad-rb[0])/step)+1):starts.update([(x,cell((0,b[1]-pad))[1]),(x,cell((0,b[3]+pad))[1])])+ for y in range(math.floor((b[1]-pad-rb[1])/step),math.ceil((b[3]+pad-rb[1])/step)+1):starts.update([(cell((b[0]-pad,0))[0],y),(cell((b[2]+pad,0))[0],y)])+ starts={n for n in starts if free(n)};ends={n for n in targets if free(n)}+ if not ends or not starts:continue+ def h(n):return min(math.dist(n,z) for z in ends)+ costs={n:0 for n in starts};prev={n:None for n in starts};heap=[(h(n),0,n) for n in starts];heapq.heapify(heap);found=None+ while heap:+ _,g,n=heapq.heappop(heap)+ if g!=costs[n]:continue+ if n in ends:found=n;break+ for dx,dy in [(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)]:+ z=(n[0]+dx,n[1]+dy)+ if not free(z) or (dx and dy and (not free((n[0]+dx,n[1])) or not free((n[0],n[1]+dy)))):continue+ ng=g+math.hypot(dx,dy)+ if ng<costs.get(z,1e99):costs[z]=ng;prev[z]=n;heapq.heappush(heap,(ng+h(z),ng,z))+ if found is None:continue+ path=[]+ while found is not None:path.append(point(found));found=prev[found]+ path.reverse()+ if len(path)<2:accepted.append(c);continue+ # Keep only changes in direction. Native EDA may round corners inside this envelope.+ reduced=[path[0]]+ for a,q,z in zip(path,path[1:],path[2:]):+ if abs((q[0]-a[0])*(z[1]-q[1])-(q[1]-a[1])*(z[0]-q[0]))>1e-8:reduced.append(q)+ reduced.append(path[-1]);rects=[b]+[[min(a[0],z[0])-.025,min(a[1],z[1])-.025,max(a[0],z[0])+.025,max(a[1],z[1])+.025] for a,z in zip(reduced,reduced[1:])]+ if any(hit(a,z,gap) for a in rects[1:] for z in fixed):continue+ accepted.append(dict(c,boxes=rects,leaderPoints=reduced))+ group['candidates']=accepted;print(group['id'],len(accepted),'routable candidates',flush=True)+ return spec+if __name__=='__main__':+ p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--out',required=True);a=p.parse_args();s=prepare(json.loads(Path(a.input).read_text()));Path(a.out).write_text(json.dumps(s,indent=2))+@@ -0,0 +1,26 @@+from pathlib import Path+import xml.etree.ElementTree as E,re,json,math,argparse,hashlib+p=argparse.ArgumentParser(description='Measure text from a single-face native KiCad stroke SVG; layout evidence only.');p.add_argument('--svg',required=True,type=Path);p.add_argument('--labels',required=True,type=Path);p.add_argument('--out',required=True,type=Path);p.add_argument('--layer',default='F.SilkS');a=p.parse_args();root=E.parse(a.svg).getroot();groups=[]+def walk(e,sw=0):+ st=dict(re.findall(r'([\w-]+):([^;]+)',e.get('style','')));sw=float(st.get('stroke-width',e.get('stroke-width',sw)))+ if e.get('class')=='stroked-text':+ desc=next((x.text for x in e if x.tag.endswith('desc')),None);points=[]+ for p in e.iter():+ if p.tag.endswith('path'):+ vals=list(map(float,re.findall(r'[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?',p.get('d',''))));points+=list(zip(vals[::2],vals[1::2]))+ if points:+ xs,ys=zip(*points);groups.append(dict(text=desc,box=[min(xs)-sw/2,min(ys)-sw/2,max(xs)+sw/2,max(ys)+sw/2]))+ for c in e:walk(c,sw)+walk(root);m=json.loads(a.labels.read_text());out=[]+for i,l in enumerate(m['labels']):+ if l['layer']!=a.layer:continue+ boxes=[]+ for line in l['text'].split('\n'):+ gs=[g for g in groups if g['text']==line]+ if not gs:raise ValueError((i,line))+ def dist(g):b=g['box'];return math.hypot((b[0]+b[2])/2-l['x'],(b[1]+b[3])/2-l['y'])+ g=min(gs,key=dist);boxes.append(g['box'])+ b=[min(v[0] for v in boxes),min(v[1] for v in boxes),max(v[2] for v in boxes),max(v[3] for v in boxes)]+ out.append(dict(index=i,text=l['text'],box=b,offset=[(b[k]-(l['x'] if k%2==0 else l['y']))/l['size'] for k in range(4)]))+a.out.write_text(json.dumps({'sourceSvgSha256':hashlib.sha256(a.svg.read_bytes()).hexdigest(),'layer':a.layer,'method':'nearest matching native stroked-text group; inspect mapping before layout','bounds':out},indent=2));print('Native SVG text bounds:',len(out))+@@ -0,0 +1,23 @@+#!/usr/bin/env python3+"""Fail-closed whole-board rectangular obstacle audit. Native CAD supplies the+transformed fitted-body/mask/hole envelopes and rendered text/leader bounds.+The inventory is independent of the obstacle export: missing geometry is an error.+"""+import argparse,json+from pathlib import Path++def overlaps(a,b):+ return a[0]<b[2] and b[0]<a[2] and a[1]<b[3] and b[1]<a[3]+def check(data):+ obs=data['obstacles'];inventory=data['inventory'];missing=[];collisions=[]+ present={(o['id'],o['kind'],o['layer']) for o in obs}+ for item in inventory:+ for layer in item['layers']:+ if (item['id'],item['kind'],layer) not in present:missing.append(dict(id=item['id'],kind=item['kind'],layer=layer))+ for label in data['marks']:+ for o in obs:+ if label['layer']==o['layer'] and any(overlaps(a,b) for a in label.get('boxes',[label['box']]) for b in o.get('boxes',[o['box']])):collisions.append(dict(mark=label['id'],obstacle=o['id'],kind=o['kind'],layer=o['layer']))+ return dict(schemaVersion=1,passed=bool(inventory) and not missing and not collisions,coverageErrors=[] if inventory else ['Whole-board inventory is empty'],missingObstacles=missing,collisions=collisions,inventoryItems=len(inventory),obstacles=len(obs),marks=len(data['marks']),nativeVerified=False,note='Conservative rectangle audit only; geometry provenance, fitted transforms, label association and native 2D/3D review remain required.')+if __name__=='__main__':+ p=argparse.ArgumentParser();p.add_argument('--input',required=True,type=Path);p.add_argument('--out',required=True,type=Path);a=p.parse_args();result=check(json.loads(a.input.read_text()));a.out.write_text(json.dumps(result,indent=2));print(('OK' if result['passed'] else 'ERROR')+f": {len(result['coverageErrors'])} coverage errors; {len(result['missingObstacles'])} missing obstacles; {len(result['collisions'])} collisions");raise SystemExit(0 if result['passed'] else 2)+@@ -0,0 +1,21 @@+import importlib.util+from pathlib import Path+sp=importlib.util.spec_from_file_location('silk',Path(__file__).with_name('silkscreen-layout.py'));m=importlib.util.module_from_spec(sp);sp.loader.exec_module(m)+def c(x,font):return dict(box=[x,0,x+1,1],position=[x+.5,.5],font=font)+# The first label's locally best choice blocks the next; beam must revisit it.+s={'bounds':[-1,-1,6,3],'clearance':0,'labels':[{'id':'a','anchor':[.5,.5],'candidates':[c(0,.8),c(2,.7)]},{'id':'b','anchor':[.5,.5],'candidates':[c(0,.8),c(0,.7)]}]}+r=m.solve(s);assert not r['unresolved'];assert next(x for x in r['placements'] if x['id']=='a')['position'][0]==2.5+s['obstacles']=[{'box':[-1,-1,6,3]}];assert len(m.solve(s)['unresolved'])==2+print('PASS: revises earlier choices, prefers larger text and reports blocked labels')++# A grouped reference/value must not reserve the empty middle between their boxes.+s={'bounds':[-1,-1,6,3],'clearance':0,'obstacles':[{'box':[1.5,0,2.5,1]}],'labels':[{'id':'pair','anchor':[1.5,.5],'candidates':[dict(box=[0,0,4,1],boxes=[[0,0,1,1],[3,0,4,1]],position=[1.5,.5],font=.6)]}]}+assert not m.solve(s)['unresolved']+# Preserve connector row order even if a larger candidate would swap labels.+s={'bounds':[-2,-2,6,6],'clearance':0,'labels':[{'id':'p1','orderGroup':'left','order':1,'anchor':[0,0],'candidates':[dict(box=[0,0,1,1],position=[.5,.5],font=.6)]},{'id':'p3','orderGroup':'left','order':3,'anchor':[0,2],'candidates':[dict(box=[0,-2,1,-1],position=[.5,-1.5],font=.8),dict(box=[0,2,1,3],position=[.5,2.5],font=.5)]}]}+r=m.solve(s);assert next(x for x in r['placements'] if x['id']=='p3')['font']==.5+print('PASS: compound bounds retain empty space and pin rows stay ordered')+# Optional MILP must trade a larger label for two readable labels rather than a tiny fallback.+s={'solver':'milp','bounds':[-1,-1,6,3],'clearance':0,'preferredMinimumFont':.3,'smallFontPenalty':1000,'distanceWeight':0,'labels':[{'id':'a','anchor':[0,0],'candidates':[c(0,.6),c(2,.4)]},{'id':'b','anchor':[0,0],'candidates':[c(0,.4),c(4,.2)]}]}+r=m.solve(s);assert not r['unresolved'];assert all(x['font']>=.3 for x in r['placements']);print('PASS: MILP avoids a tiny fallback when repacking fits both labels')+@@ -0,0 +1,7 @@+import importlib.util,pathlib,tempfile,json+p=pathlib.Path(__file__).with_name('silkscreen-layout.py');s=importlib.util.spec_from_file_location('layout',p);m=importlib.util.module_from_spec(s);s.loader.exec_module(m)+spec=dict(bounds=[0,0,10,10],obstacles=[dict(box=[0,0,2,2])],labels=[dict(id='C1',anchor=[4,4],candidates=[dict(font=.5,position=[1,1],box=[.5,.5,1.5,1.5]),dict(font=.5,position=[4,4],box=[3,3,5,5])]),dict(id='C2',anchor=[1,1],candidates=[])],solver='milp')+with tempfile.TemporaryDirectory() as td:+ m.EVENT_FILE=str(pathlib.Path(td)/'events.jsonl');m.EVENT_ID='test';r=m.solve(spec);assert r['unresolved']==['C2'];events=[json.loads(l) for l in pathlib.Path(m.EVENT_FILE).read_text().splitlines()];assert any(e.get('reason')=='intersects a reserved obstacle' for e in events);assert any(e.get('reason','').startswith('clears') for e in events);assert all(e['solveId']=='test' for e in events);assert r['nativeVerified'] is False+print('PASS: real rejection/feasible events, solve identity, unresolved MILP result and native-review boundary')+@@ -0,0 +1,17 @@+import importlib.util,pathlib,unittest+p=pathlib.Path(__file__).with_name('silkscreen-obstacle-check.py');s=importlib.util.spec_from_file_location('audit',p);m=importlib.util.module_from_spec(s);s.loader.exec_module(m)+class Audit(unittest.TestCase):+ def test_missing_body_between_pads(self):+ d=dict(inventory=[dict(id='C11',kind='body',layers=['F.SilkS'])],obstacles=[],marks=[])+ self.assertFalse(m.check(d)['passed']);self.assertEqual(len(m.check(d)['missingObstacles']),1)+ def test_via_requires_both_faces(self):+ d=dict(inventory=[dict(id='v1',kind='via',layers=['F.SilkS','B.SilkS'])],obstacles=[dict(id='v1',kind='via',layer='F.SilkS',box=[0,0,1,1])],marks=[])+ self.assertEqual(m.check(d)['missingObstacles'][0]['layer'],'B.SilkS')+ def test_text_anchor_outside_but_letters_cross_body(self):+ d=dict(inventory=[dict(id='C4',kind='body',layers=['F.SilkS'])],obstacles=[dict(id='C4',kind='body',layer='F.SilkS',box=[0,0,2,1])],marks=[dict(id='C11-label',layer='F.SilkS',box=[-1,.2,.3,.6])])+ self.assertEqual(len(m.check(d)['collisions']),1)+ def test_bottom_text_does_not_hit_top_body(self):+ d=dict(inventory=[dict(id='C4',kind='body',layers=['F.SilkS'])],obstacles=[dict(id='C4',kind='body',layer='F.SilkS',box=[0,0,2,1])],marks=[dict(id='label',layer='B.SilkS',box=[.1,.2,.3,.6])])+ self.assertTrue(m.check(d)['passed'])+unittest.main()+@@ -0,0 +1,147 @@+#!/usr/bin/env python3+"""AI Flow silkscreen observer: persisted solver events, no provider calls or CAD edits."""+import argparse,hashlib,json,os,pathlib,subprocess,sys,time,threading,http.server,urllib.request,urllib.parse,signal+P=pathlib.Path;ROOT=P(__file__).resolve().parent;REG=P.home()/'.adom/instances/aiflow-silkscreen';REG.mkdir(parents=True,exist_ok=True)+def atomic(p,d):+ q=p.with_suffix('.tmp');q.write_text(json.dumps(d,indent=2));q.replace(p)+p=argparse.ArgumentParser();p.add_argument('action',choices=['start','serve','show','ls','state','control','stop']);p.add_argument('--run',required=True,type=P);p.add_argument('--ai-thread',required=True);p.add_argument('--port',default='auto');p.add_argument('--surface',choices=['wv','pup'],default='wv');p.add_argument('--target');p.add_argument('--url');p.add_argument('--json',default='{}');p.add_argument('--reason');a=p.parse_args();run=a.run.resolve();key=hashlib.sha256((a.ai_thread+str(run)).encode()).hexdigest()[:16];reg=REG/(key+'.json');snap=REG/(key+'-snapshot.json');folder=run/'silkscreen-dashboard';folder.mkdir(exist_ok=True);events=folder/'events.jsonl';manifest=folder/'board.json';version=hashlib.sha256((ROOT/'index.html').read_bytes()+P(__file__).read_bytes()).hexdigest()[:12]+def entry():+ try:+ e=json.loads(reg.read_text());r=json.load(urllib.request.urlopen('http://127.0.0.1:'+str(e['port'])+'/health',timeout=2));return e if r.get('key')==key else None+ except Exception:return None+def request(e,path,data=None):+ body=None if data is None else json.dumps(dict(data,aiThread=a.ai_thread)).encode();return json.load(urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:'+str(e['port'])+path,data=body,headers={'Content-Type':'application/json'}),timeout=5))+if a.action=='ls':+ for f in REG.glob('*.json'):+ if f.name.endswith('-snapshot.json'):continue+ e=json.loads(f.read_text())+ try:os.kill(e['pid'],0)+ except ProcessLookupError:f.unlink();continue+ print(json.dumps(dict(e,versionDrift=e.get('version')!=version)))+ sys.exit()+e=entry()+if a.action in ['start','show']:+ if not e:+ log=open(folder/'server.log','ab');subprocess.Popen([sys.executable,str(P(__file__).resolve()),'serve','--run',str(run),'--ai-thread',a.ai_thread,'--port',a.port],stdout=log,stderr=log,start_new_session=True)+ for _ in range(80):+ time.sleep(.1);e=entry()+ if e:break+ if not e:raise SystemExit('ERROR: server did not become healthy; see '+str(folder/'server.log'))+ url=a.url or ('http://'+str(e['port'])+'.localhost:'+urllib.parse.urlparse((P.home()/'.adom/hd-proxy-url').read_text().strip()).port.__str__()+'/' if (P.home()/'.adom/hd-proxy-url').exists() else 'http://127.0.0.1:'+str(e['port'])+'/')+ if a.action=='show':+ if a.surface=='wv':opened=subprocess.run(['adom-cli','hydrogen','webview','open-or-refresh','--name','AI Flow silkscreen','--url',url],check=True,capture_output=True,text=True);print(opened.stdout);e['tabId']=json.loads(opened.stdout).get('tabId');atomic(reg,e)+ else:+ if not a.target or not a.url:raise SystemExit('ERROR: pup needs --target and a desktop-reachable --url')+ subprocess.run(['adom-bridge','pup_open_window',json.dumps({'url':url,'background':True}),'--target',a.target,'--ai-thread',a.ai_thread],check=True)+ print(json.dumps(dict(e,url=url,reused=True)));sys.exit()+if a.action in ['state','control','stop']:+ if not e:raise SystemExit('ERROR: not running; use start')+ if a.action=='stop' and not a.reason:raise SystemExit('ERROR reason_required: explain why this task-owned server should stop')+ print(json.dumps(request(e,'/shutdown' if a.action=='stop' else '/control' if a.action=='control' else '/state',dict(json.loads(a.json),reason=a.reason) if a.action!='state' else None)));sys.exit()+if e:print('OK: already running');sys.exit()+settingspath=REG/'settings.json';settings=json.loads(settingspath.read_text()) if settingspath.exists() else dict(ttlHours=24,speed=4,showObstacles=True)+state=json.loads(snap.read_text()) if snap.exists() else dict(mode='live',cursor=-1,playing=False,layer='F.SilkS',view='2d');clients=set();lock=threading.RLock();last=time.time();revision=0;history=[];offset=0++def payload():return dict(state,settings=settings,events=history,aiThread=a.ai_thread,version=version)+def save():atomic(snap,state)+def broadcast():+ global revision+ revision+=1+ # Bounded state messages contain the current event; history is fetched separately.+ data=dict(state,settings=settings,event=history[state['cursor']] if 0<=state['cursor']<len(history) else None,total=len(history),revision=revision)+ for q in list(clients):+ try:q.write(('event: state\ndata: '+json.dumps(data)+'\n\n').encode());q.flush()+ except Exception:clients.discard(q)+class Handler(http.server.BaseHTTPRequestHandler):+ def log_message(self,*args):pass+ def response(self,data,code=200,ctype='application/json'):+ b=data if isinstance(data,bytes) else json.dumps(data).encode();self.send_response(code);self.send_header('Content-Type',ctype);self.send_header('Cache-Control','no-store');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b)+ def do_GET(self):+ global last+ last=time.time();path=urllib.parse.urlparse(self.path).path+ if path=='/health':return self.response(dict(key=key,version=version))+ if path=='/state':return self.response(payload())+ if path=='/settings':return self.response(dict(settings=settings,enums={'ttlHours':'1..168','speed':'0.5..60','showObstacles':'boolean'}))+ if path=='/stream':+ self.send_response(200);self.send_header('Content-Type','text/event-stream');self.send_header('Cache-Control','no-cache');self.send_header('X-Accel-Buffering','no');self.end_headers();self.wfile.write((':'+(' '*2048)+'\n\n').encode());clients.add(self.wfile);broadcast()+ try:+ while True:time.sleep(15);self.wfile.write(b': heartbeat\n\n');self.wfile.flush()+ except Exception:clients.discard(self.wfile)+ return+ if path in ['/','/index.html']:return self.response((ROOT/'index.html').read_bytes(),ctype='text/html')+ if path=='/board.json':return self.response(manifest.read_bytes() if manifest.exists() else b'{"obstacles":[],"labels":[],"bounds":[0,0,100,100]}')+ if path.startswith('/models/'):+ f=(folder/path.lstrip('/')).resolve()+ if not f.is_relative_to((folder/'models').resolve()) or not f.is_file():return self.response({'error':'not found'},404)+ return self.response(f.read_bytes(),ctype='model/gltf-binary')+ self.response({'error':'not found'},404)+ def do_POST(self):+ global last+ last=time.time()+ try:+ if int(self.headers.get('Content-Length','0'))>65536:raise ValueError('request too large')+ body=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))))+ if not body.get('aiThread'):return self.response(dict(errorCode='caller_identity_required',hint='Pass aiThread or CLI --ai-thread.'),400)+ with lock:+ if self.path=='/shutdown':+ if not body.get('reason'):raise ValueError('reason_required')+ self.response({'ok':True});threading.Thread(target=shutdown,daemon=True).start();return+ if self.path=='/settings':+ for k,v in body.items():+ if k=='aiThread':continue+ if k=='ttlHours' and isinstance(v,(int,float)) and 1<=v<=168:settings[k]=v+ elif k=='speed' and isinstance(v,(int,float)) and .5<=v<=60:settings[k]=v+ elif k=='showObstacles' and isinstance(v,bool):settings[k]=v+ else:raise ValueError('Allowed settings: ttlHours 1..168, speed .5..60, showObstacles boolean')+ atomic(settingspath,settings)+ elif self.path=='/control':+ for k,v in body.items():+ if k in ['aiThread','reason']:continue+ if k=='mode' and v in ['live','replay']:state[k]=v+ elif k=='layer' and v in ['F.SilkS','B.SilkS']:state[k]=v+ elif k=='view' and v in ['2d','3d']:state[k]=v+ elif k in ['playing','summary'] and isinstance(v,bool):state[k]=v+ elif k=='cursor' and isinstance(v,int) and -1<=v<len(history):state[k]=v+ else:raise ValueError('Allowed controls: mode live/replay, layer F.SilkS/B.SilkS, view 2d/3d, playing boolean, cursor within event history')+ if state['mode']=='live':state['cursor']=len(history)-1;state['playing']=False+ else:return self.response({'error':'not found'},404)+ save();broadcast();self.response({'ok':True,'state':state})+ except (ValueError,TypeError) as ex:self.response({'error':str(ex)},400)+http.server.ThreadingHTTPServer.allow_reuse_address=True+server=http.server.ThreadingHTTPServer(('127.0.0.1',0 if a.port=='auto' else int(a.port)),Handler);server.daemon_threads=True+atomic(reg,dict(pid=os.getpid(),port=server.server_port,thread=a.ai_thread,run=str(run),version=version,startedAt=time.time()))+def shutdown():+ save();current=json.loads(reg.read_text()) if reg.exists() else {};reg.unlink(missing_ok=True)+ if current.get('tabId'):+ subprocess.run(['adom-cli','hydrogen','workspace','remove-tab','--tab-id',current['tabId']],capture_output=True)+ server.shutdown()+def watch():+ global offset,history+ previous=time.time()+ while True:+ time.sleep(.05)+ with lock:+ if events.exists():+ with events.open() as f:+ f.seek(offset)+ while True:+ pos=f.tell();line=f.readline()+ if not line or not line.endswith('\n'):break+ try:history.append(json.loads(line))+ except json.JSONDecodeError:pass+ offset=f.tell()+ if state['mode']=='live' and state['cursor']!=len(history)-1:state['cursor']=len(history)-1;broadcast()+ if state['mode']=='replay' and state['playing']:+ start=max([i for i,e in enumerate(history) if e.get('kind')=='start'] or [0])+ eligible=[i for i in range(start,len(history)) if not state.get('summary') or history[i].get('kind') in ['placement','unresolved']]+ speed=max(1,len(eligible)/5) if state.get('summary') else settings['speed']+ elapsed=time.time()-previous+ if elapsed>=1/speed:+ remaining=[i for i in eligible if i>state['cursor']];count=max(1,int(elapsed*speed));previous+=count/speed+ if remaining:state['cursor']=remaining[min(count,len(remaining))-1]+ if len(remaining)<=count:state['cursor']=len(history)-1;state['playing']=False+ broadcast()+ else:previous=time.time()+ if time.time()-last>settings['ttlHours']*3600:shutdown();return+threading.Thread(target=watch,daemon=True).start();signal.signal(signal.SIGTERM,lambda *_:threading.Thread(target=shutdown,daemon=True).start());print(json.dumps({'port':server.server_port}),flush=True);server.serve_forever();save();reg.unlink(missing_ok=True)+@@ -0,0 +1,29 @@+<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>AI Flow · Silkscreen</title><style>+:root{color-scheme:dark;font:14px system-ui;color:#e7edef;background:#141a20}*{box-sizing:border-box}body{margin:0;height:100vh;display:grid;grid-template-rows:48px 1fr 66px}header,footer{display:flex;align-items:center;gap:12px;padding:8px 18px;background:#1d252d;border-bottom:1px solid #35424d}header b{font-size:17px}button,select,input{font:inherit;color:inherit;background:#26333e;border:1px solid #536371;border-radius:5px;padding:7px}button{cursor:pointer}button:hover{border-color:#51cdc2}button.active{background:#24504f}main{display:grid;grid-template-columns:1fr 300px;min-height:0}#stage{position:relative;overflow:hidden;background:#111a1f}canvas{display:block;width:100%;height:100%;touch-action:none}#three{position:absolute;inset:0;display:none}aside{padding:18px;overflow:auto;background:#1a222a}h2{font-size:23px;margin:8px 0}p{line-height:1.5;color:#b5c4cd}.badge{border:1px solid #648076;border-radius:12px;padding:3px 10px;color:#7be0c7;font-size:12px}#native{color:#ffd293}.muted{color:#93a5b1;font-size:12px}#scrub{flex:1}#progress{height:5px;background:#33444f;margin:14px 0}#progress span{display:block;background:#48cdbb;height:100%;width:0}#legend{display:grid;gap:8px;margin-top:24px}.swatch{display:inline-block;width:12px;height:12px;margin-right:8px;border-radius:2px}#settings{position:absolute;right:20px;top:55px;z-index:8;background:#26313b;border:1px solid #566676;padding:18px;border-radius:8px}#settings label{display:block;margin:12px 0}#hud{position:absolute;left:16px;top:14px;pointer-events:none;background:#142028de;border:1px solid #41535e;padding:10px 14px;border-radius:6px}#error{color:#ff9999}a{color:#67dacc}footer{border-top:1px solid #35424d}#fit{margin-left:auto}@media(max-width:800px){main{grid-template-columns:1fr 220px}header{gap:5px;padding:7px}header b{font-size:14px}}+</style></head><body><header><b>Adom AI Flow · Silkscreen</b><span id="mode" class="badge">Connecting</span><select id="face" aria-label="Board face"><option value="F.SilkS">Top</option><option value="B.SilkS">Bottom</option></select><button id="two" class="active">2D layout</button><button id="threeBtn">3D inspection</button><button id="fit">Fit board</button><button id="gear" aria-label="Settings">⚙</button></header><main><div id="stage"><canvas id="board"></canvas><div id="three"></div><div id="hud"><div id="headline">Loading board geometry</div><div class="muted" id="geometry"></div></div></div><aside><div class="muted">Placement decision</div><h2 id="part">Waiting</h2><p id="reason">No solver events yet.</p><div id="progress"><span></span></div><div id="count"></div><p id="native">Native CAD review pending</p><label>Inspect component <select id="pick"><option value="">Whole board</option></select></label><div id="legend"><div><i class="swatch" style="background:#516a78"></i>Fitted body / reserved envelope</div><div><i class="swatch" style="background:#cbad4c"></i>Via / mask opening</div><div><i class="swatch" style="background:#ffb963"></i>Sampled candidate</div><div><i class="swatch" style="background:#57dcc4"></i>Selected proposal</div><div><i class="swatch" style="background:#f18389"></i>Existing collision</div></div><p class="muted">Selection is a proposal, not an edit to the EDA board. Native text bounds drive collision checks. Preview text uses the browser font.</p><div id="error"></div></aside></main><footer><button id="live">Follow live</button><button id="play">Replay</button><input type="range" id="scrub" min="-1" max="0" value="-1" aria-label="Event timeline"><span id="stamp" class="muted">0 / 0</span><button id="record">Record walkthrough</button><button id="short">Record 5s overview</button></footer><div id="settings" hidden><b>Playback & display</b><label>Events per second <input id="speed" type="number" min="0.5" max="60" step="0.5"></label><label><input id="obstacles" type="checkbox"> Show obstacles</label><label><input id="follow" type="checkbox" checked> Follow selected component</label><label>Idle shutdown, hours <input id="ttl" type="number" min="1" max="168"></label><p class="muted">No AI/provider calls. Solver runs independently of playback.</p></div>+<script type="module">+const $=s=>document.querySelector(s),canvas=$('#board'),ctx=canvas.getContext('2d');let data,history=[],state={},selected='',bounds,view,drag=null,viewer=null,scene=null,labelTexture=null,threeGroups=[],recording=false;const api=async(path,body)=>{const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...body,aiThread:state.aiThread||'Silkscreen dashboard user'})});const j=await r.json();if(!r.ok)throw Error(j.error);if(j.state)await apply(j.state);return j};window.silkscreen={ready:false};+let cameraAnimation=0;function frame(b){cancelAnimationFrame(cameraAnimation);if(recording&&!state.summary&&view){const from=[...view],start=performance.now();const tick=now=>{const t=Math.min(1,(now-start)/220),ease=t*t*(3-2*t);view=from.map((v,i)=>v+(b[i]-v)*ease);draw();if(t<1)cameraAnimation=requestAnimationFrame(tick)};cameraAnimation=requestAnimationFrame(tick)}else{view=[...b];draw()}}+function fitPart(ref){const os=data.obstacles.filter(o=>o.reference===ref&&o.layer===state.layer);if(!os.length)return;const b=[Math.min(...os.map(o=>o.box[0]))-3,Math.min(...os.map(o=>o.box[1]))-3,Math.max(...os.map(o=>o.box[2]))+3,Math.max(...os.map(o=>o.box[3]))+3];frame(b);if(viewer){const cam=viewer.getCamera();cam.setTarget(new window.BABYLON.Vector3((b[0]+b[2])/2,-(b[1]+b[3])/2,0));cam.radius=Math.max(b[2]-b[0],b[3]-b[1])*1.8}}+function proposals(){const map=new Map();let start=0;for(let k=Math.min(state.cursor,history.length-1);k>=0;k--)if(history[k].kind==='start'){start=k;break}for(let k=start;k<=state.cursor&&k<history.length;k++){const e=history[k];if(e.kind==='placement')map.set(e.id,e.candidate)}return map}+function paint(c,w,h,extent,texture=false){c.clearRect(0,0,w,h);if(!texture){c.fillStyle='#122128';c.fillRect(0,0,w,h)}const bw=extent[2]-extent[0],bh=extent[3]-extent[1],scale=Math.min(w/bw,h/bh)*.93,sx=texture?w/bw:scale,sy=texture?h/bh:scale,ox=texture?-extent[0]*sx:(w-bw*scale)/2-extent[0]*scale,oy=texture?-extent[1]*sy:(h-bh*scale)/2-extent[1]*scale;c.save();c.translate(ox,oy);c.scale(sx,sy);c.lineWidth=1/scale;+function rect(b,color,fill=true){c.fillStyle=color;c.strokeStyle=color;if(fill)c.fillRect(b[0],b[1],b[2]-b[0],b[3]-b[1]);else c.strokeRect(b[0],b[1],b[2]-b[0],b[3]-b[1])}+if(!texture){rect(data.bounds,'#233b32');if(state.settings?.showObstacles)for(const o of data.obstacles){if(o.layer!==state.layer)continue;const col=o.kind.startsWith('fitted')?(o.reference===selected?'#607b87':'#394d58'):o.kind.startsWith('via')?'#bd9f43':'#889081';if(o.kind.startsWith('via')){const b=o.box;c.beginPath();c.arc((b[0]+b[2])/2,(b[1]+b[3])/2,(b[2]-b[0])/2,0,Math.PI*2);c.strokeStyle=col;c.stroke()}else rect(o.box,col,true)}}+const placed=proposals();const replaced=new Set([...placed.values()].flatMap(p=>(p.children||[]).map(x=>x.index)));+function label(l,color,b){c.save();c.translate(l.x??l.position?.[0]??0,l.y??l.position?.[1]??0);c.rotate(-(l.angle||0)*Math.PI/180);c.font=`${l.size||l.font||.5}px sans-serif`;c.textAlign='center';c.textBaseline='middle';c.fillStyle=color;(l.text||'').split('\n').forEach((s,i,all)=>c.fillText(s,0,(i-(all.length-1)/2)*(l.size||l.font||.5)*1.5));c.restore();if(!texture&&b)rect(b,color,false)}+for(let i=0;i<data.labels.length;i++){const l=data.labels[i];if(l.layer!==state.layer||replaced.has(i))continue;label(l,l.collision?'#f1838999':'#bfccc666')}+for(const p of placed.values()){if(p.layer&&p.layer!==state.layer)continue;for(const child of p.children||[p]){const original=child.index!=null?data.labels[child.index]:null;if(original&&original.layer!==state.layer)continue;label(child,'#79f5d9',texture?null:child.box)}}+const e=history[state.cursor];if(!texture&&e?.kind==='candidate'){for(const b of e.candidate.boxes||[e.candidate.box])rect(b,'#ffbd70',false);for(const child of e.candidate.children||[e.candidate])label(child,'#ffbd70')}c.restore();if(!texture&&recording){c.fillStyle='#101820df';c.fillRect(12,h-62,w-24,48);c.fillStyle='#dfece8';c.font='16px system-ui';c.fillText('REPLAY · '+(e?.id||'Silkscreen')+' · '+(e?.reason||e?.kind||''),24,h-33)} }+function draw(){if(!data||!view)return;const r=canvas.getBoundingClientRect();canvas.width=Math.max(1,r.width*devicePixelRatio);canvas.height=Math.max(1,r.height*devicePixelRatio);paint(ctx,canvas.width,canvas.height,view);if(labelTexture){const t=labelTexture.getContext();paint(t,2048,2048,data.bounds,true);labelTexture.update(false)}}+async function show3d(){if(viewer)return;$('#headline').textContent='Loading 3D geometry';try{await import('https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260915a');const B=window.BABYLON;viewer=window.Adom3DViewerBabylon9.init($('#three'),{zUp:true,showGround:false,showViewCube:false});scene=viewer.getScene();const b=data.bounds,cx=(b[0]+b[2])/2,cy=(b[1]+b[3])/2;const board=B.MeshBuilder.CreateBox('Board envelope',{width:b[2]-b[0],height:b[3]-b[1],depth:1.6},scene);board.position.set(cx,-cy,-.8);const bm=new B.PBRMaterial('board',scene);bm.albedoColor=B.Color3.FromHexString('#24563b');bm.metallic=0;bm.roughness=.85;board.material=bm;+for(const o of data.obstacles.filter(o=>o.kind.startsWith('fitted'))){const b=o.box;const mesh=B.MeshBuilder.CreateBox(o.reference,{width:b[2]-b[0],height:b[3]-b[1],depth:o.height||1},scene);mesh.position.set((b[0]+b[2])/2,-(b[1]+b[3])/2,(o.height||1)/2+.03);const mat=new B.StandardMaterial('reserved envelope',scene);mat.diffuseColor=B.Color3.FromHexString('#5b707d');mat.alpha=.72;mesh.material=mat;threeGroups.push({mesh,o});if(o.glb){try{const imported=await B.SceneLoader.ImportMeshAsync('',o.glb,'',scene),group=new B.TransformNode('fitted_'+o.reference,scene);for(const m of imported.meshes)if(!m.parent)m.parent=group;group.scaling.setAll(1000);const theta=(o.at[2]||0)*Math.PI/180,dx=o.offset?.[0]||0,dy=o.offset?.[1]||0;group.position.set(o.at[0]+dx*Math.cos(theta)-dy*Math.sin(theta),-o.at[1]+dx*Math.sin(theta)+dy*Math.cos(theta),o.offset?.[2]||0);group.rotation.z=theta;mesh.setEnabled(false);const buckets=new Map();for(const m of imported.meshes){if(!m.getTotalVertices())continue;m.computeWorldMatrix(true);const mat=m.material,key=JSON.stringify([mat?.albedoColor?.asArray(),mat?.diffuseColor?.asArray(),mat?.emissiveColor?.asArray(),mat?.alpha,mat?.metallic,mat?.roughness]);if(!buckets.has(key))buckets.set(key,[]);buckets.get(key).push(m)}for(const ms of buckets.values()){const mat=ms[0].material;for(const m of ms)m.material=mat;if(ms.length>1)B.Mesh.MergeMeshes(ms,true,true,undefined,false,false)}}catch(error){$('#error').textContent='Model fallback for '+o.reference+': '+error.message}}}+labelTexture=new B.DynamicTexture('silkscreen proposals',{width:2048,height:2048},scene,false);labelTexture.hasAlpha=true;const plane=B.MeshBuilder.CreatePlane('Silkscreen preview',{width:b[2]-b[0],height:b[3]-b[1]},scene);plane.position.set(cx,-cy,.015);const pm=new B.StandardMaterial('silkscreen',scene);pm.diffuseTexture=labelTexture;pm.opacityTexture=labelTexture;pm.useAlphaFromDiffuseTexture=true;pm.emissiveColor=B.Color3.White();pm.disableLighting=true;pm.backFaceCulling=false;plane.material=pm;viewer.getCamera().setTarget(new B.Vector3(cx,-cy,0));viewer.getCamera().radius=100;viewer.getCamera().beta=.65;viewer.getCamera().alpha=-1.6;viewer.getCamera().minZ=.01;viewer.getCamera().upperRadiusLimit=300;draw();$('#geometry').textContent='Fitted component models · rectangular board preview · native CAD review still required';}catch(e){$('#error').textContent=e.message}}+async function apply(s){const oldTotal=history.length;if(s.events)history=s.events;else if(s.total!==oldTotal){const full=await(await fetch('state')).json();history=full.events}state={...state,...s};window.silkscreen.state=state;const e=history[state.cursor];$('#mode').textContent=state.mode==='live'?'Live solver feed':'Recorded-event replay';$('#part').textContent=e?.id||'Whole board';$('#reason').textContent=e?.reason|| (e?.kind==='complete'?`Search complete; ${e.unresolved.length} unresolved groups.`:'Waiting for the next solver event.');$('#headline').textContent=e?.kind==='complete'?'Candidate layout · review pending':e?.id?`${e.id} · ${e.kind}`:'ESC silkscreen placement';$('#face').value=state.layer;$('#stamp').textContent=`${Math.max(0,state.cursor+1)} / ${history.length}`;$('#scrub').max=Math.max(0,history.length-1);$('#scrub').value=state.cursor;$('#count').textContent=`${proposals().size} selected groups`;$('#progress span').style.width=(history.length?(state.cursor+1)/history.length*100:0)+'%';$('#play').textContent=state.playing?'Pause':'Replay';$('#speed').value=state.settings.speed;$('#ttl').value=state.settings.ttlHours;$('#obstacles').checked=state.settings.showObstacles;if(e?.id&&selected!==e.id&&$('#follow').checked){selected=e.id;fitPart(selected)}$('#three').style.display=state.view==='3d'?'block':'none';$('#board').style.visibility=state.view==='3d'?'hidden':'visible';if(state.view==='3d'){await show3d();if(scene){const plane=scene.getMeshByName('Silkscreen preview');if(plane)plane.position.z=state.layer==='B.SilkS'?-1.615:.015;if(s.layer)viewer.getCamera().beta=state.layer==='B.SilkS'?Math.PI-.65:.65}}draw()}+try{data=await(await fetch('board.json')).json();bounds=data.bounds;view=[...bounds];const refs=[...new Set(data.obstacles.filter(o=>o.kind.startsWith('fitted')).map(o=>o.reference))].sort();for(const r of refs){const o=document.createElement('option');o.value=r;o.textContent=r;$('#pick').append(o)}$('#geometry').textContent=`${refs.length} fitted bodies · ${data.viaCount} vias · native measured text bounds`;+await apply(await(await fetch('state')).json());const stream=new EventSource('stream');stream.addEventListener('state',e=>apply(JSON.parse(e.data)).catch(e=>$('#error').textContent=e.message));stream.onerror=()=>$('#mode').textContent='Reconnecting';window.silkscreen.ready=true;+$('#live').onclick=()=>api('control',{mode:'live'});$('#play').onclick=()=>api('control',{mode:'replay',summary:false,playing:!state.playing,cursor:state.cursor>=history.length-1?-1:state.cursor});$('#scrub').oninput=e=>api('control',{mode:'replay',playing:false,cursor:+e.target.value});$('#face').onchange=e=>api('control',{layer:e.target.value});$('#two').onclick=()=>api('control',{view:'2d'});$('#threeBtn').onclick=()=>api('control',{view:'3d'});$('#fit').onclick=()=>{selected='';frame(bounds);if(viewer){const b=bounds;viewer.getCamera().setTarget(new window.BABYLON.Vector3((b[0]+b[2])/2,-(b[1]+b[3])/2,0));viewer.getCamera().radius=100}};$('#pick').onchange=e=>{selected=e.target.value;$('#follow').checked=false;selected?fitPart(selected):frame(bounds)};$('#gear').onclick=()=>$('#settings').hidden=!$('#settings').hidden;$('#speed').onchange=e=>api('settings',{speed:+e.target.value});$('#ttl').onchange=e=>api('settings',{ttlHours:+e.target.value});$('#obstacles').onchange=e=>api('settings',{showObstacles:e.target.checked});+canvas.onwheel=e=>{e.preventDefault();const f=e.deltaY>0?1.15:.87;const r=canvas.getBoundingClientRect(),px=(e.clientX-r.left)/r.width,py=(e.clientY-r.top)/r.height,w=view[2]-view[0],h=view[3]-view[1];view=[view[0]+w*px*(1-f),view[1]+h*py*(1-f),view[0]+w*px*(1-f)+w*f,view[1]+h*py*(1-f)+h*f];draw()};canvas.onpointerdown=e=>{drag={x:e.clientX,y:e.clientY,v:[...view]};canvas.setPointerCapture(e.pointerId)};canvas.onpointermove=e=>{if(!drag)return;const r=canvas.getBoundingClientRect(),dx=(e.clientX-drag.x)/r.width*(drag.v[2]-drag.v[0]),dy=(e.clientY-drag.y)/r.height*(drag.v[3]-drag.v[1]);view=[drag.v[0]-dx,drag.v[1]-dy,drag.v[2]-dx,drag.v[3]-dy];draw()};canvas.onpointerup=()=>drag=null;+async function recordReplay(summary=false){if(recording)return;const latest=history.map((e,i)=>e.kind==='start'?i:-1).filter(i=>i>=0).pop()||0;await api('control',{mode:'replay',playing:false,summary,cursor:latest-1,view:'2d'});$('#follow').checked=!summary;frame(bounds);recording=true;const chunks=[],rec=new MediaRecorder(canvas.captureStream(30),{mimeType:'video/webm'});rec.ondataavailable=e=>chunks.push(e.data);rec.onstop=()=>{recording=false;const blob=new Blob(chunks,{type:'video/webm'}),a=document.createElement('a');window.silkscreen.recordingBlob=blob;a.href=URL.createObjectURL(blob);a.download='silkscreen-replay.webm';a.click();$('#record').textContent='Record replay';draw()};rec.start();$('#record').textContent='Recording replay…';await api('control',{playing:true});const timer=setInterval(()=>{draw();if(!state.playing&&state.cursor>=history.length-1){clearInterval(timer);rec.stop()}},100)};$('#record').onclick=()=>recordReplay(false);$('#short').onclick=()=>recordReplay(true);+new ResizeObserver(draw).observe($('#stage'));draw();}catch(e){$('#error').textContent=e.stack}+</script></body></html>+@@ -0,0 +1,24 @@+# Silkscreen dashboard++An optional, EDA-neutral observer of placement candidates and recorded solver decisions. It makes no AI/provider calls and never edits a native board. Native geometry, text metrics, undoable edits and 2D/3D refresh belong to each EDA bridge. The ESC integration currently exports KiCad geometry; Fusion and Altium adapters still need implementation and native tests. Do not claim those integrations work from schema compatibility alone.++## Displaying this app (REQUIRED — read before showing anything)++Use `adom-aiflow silkscreen-dashboard show --ai-thread "<name>" --run <run>` to open the named Hydrogen webview. Never use VS Code Simple Browser. `--surface pup` requires an explicit desktop `--target` and desktop-reachable `--url`. Keep standard workspace navigation. Reuse the same run/thread instance; `ls` shows instances and version drift.++## Inputs and live observation++Place `board.json` and an append-only `events.jsonl` in `<run>/silkscreen-dashboard/`. These are separate artifacts; never edit the measurement ledger `run.jsonl`. The board manifest has `bounds` in mm, `labels`, `obstacles`, `viaCount`, source-board identity and validation state. Each label carries text, x/y, size, angle, face and an optional existing-collision flag. Each obstacle carries a stable reference, kind, layer and conservative world-space box. Model obstacles may additionally supply a relative GLB URL, footprint `at` in KiCad XY/degrees, model offset and height. This initial 3D adapter supports top-mounted, unit-scale models without separate model rotations; bake other transforms into the supplied GLB or refuse the preview. The viewport's board slab is an envelope, not a manufactured-board export; pads, routed copper and drilled geometry are not yet native-equivalent. Keep that distinction visible.++`adom-aiflow silkscreen-layout --input candidates.json --out layout.json --events <run>/silkscreen-dashboard/events.jsonl --run <run> --ai-thread "<name>"` emits timestamped start, sampled candidate, selected proposal, unresolved and completion events with a solve ID. Events are generated by real computation, not invented for a film. Candidate samples are bounded: this is not every internal solver iteration. Pre-filtered inputs cannot reconstruct prior rejected candidates. The event reason describes the observable constraint/result, not private internal reasoning.++SSE updates do not reload the DOM. Live follows new events; replay has shared play/pause, scrub and speed controls. Playback is independent of solver speed. A selected proposal is not a native edit. Native application/DRC/assembled-visibility evidence must be recorded separately; the dashboard never promotes proposal success into native acceptance.++## Recording++Record replay exports a WebM of the 2D canvas with a persistent REPLAY label and the current recorded decision. Detailed review and a short final-video segment are separate artifacts. Preserve original event timestamps and raw native per-step clips. Do not call replay footage a live native edit or a KiCad recording. Verify the exported video actually plays. The browser-font preview does not replace KiCad's measured stroke-font geometry. Final-video pacing should not force the solver to sleep or trigger extra AI calls for every label.++## Acceptance++Audit the whole board on both faces: fitted body projections including the area between pads, every via/hole, actual mask openings, labels and leader paths. Missing Fab geometry is not empty space. Keep each label associated with its own component. Unresolved labels remain visible and block acceptance. The independent inventory in `silkscreen-audit` must be derived from the whole board, not from the same potentially incomplete obstacle list.+@@ -0,0 +1,94 @@+# Two-sided service silkscreen++The `silkscreen` AI-owned stage runs after analysis/net review and before the final native 3D tour. Plan label space at placement time. Read the reusable [InstaPCB silkscreen skill](https://wiki.adom.inc/adom/instapcb/files/skills/instapcb-silkscreen/SKILL.md).+++Treat silkscreen as the board's built-in service manual. Add useful information generously, with a visual hierarchy and space between labels. Do not fill space with ambiguous or unreadable text.++## Process profile and provenance++For the InstaPCB profile requested by Adam (Adom CEO, 2026-09-16), use approximately 0.8 mm reference designators and 0.5 mm secondary value text where space allows. He reports that InstaPCB's UV fiber laser process can render readable 0.5 mm text. This is a named process target, not a universal fab minimum or a measured acceptance result. Verify the current station profile for stroke width, contrast, mask registration and clearances; retain the profile/version and inspect a physical coupon when fabrication qualification is required. Do not infer minimum stroke from text height. Preserve other fabs' rules and never disable DRC globally to force microtext through. Treat two-sided marking cost as a property of the selected service, not a universal free option.++## Plan before placement; finish after copper stabilizes++1. Read the actual schematic, BOM, board and approved requirements. Build a label manifest with text, source, reference/net, face, size, orientation and purpose. Reserve service-label space during placement. Finalize after routing, pours and analysis, before final DRC and the 3D tour. Return here whenever a pinout, rating or placement changes.+2. Put board name, function, revision and an enduring project/documentation link on the board. Use an approved logo if available. Keep decoration subordinate to connection and safety information. Do not invent certifications, copyright ownership or electrical ratings.+3. Label power inputs and returns, polarity, connector pin 1 and every accessible signal, machine pin/contact functions, programming/debug pinout, switch actions, LED meanings, test points and mounting orientation. Verify pin labels against actual numbered pads and nets, not the connector's apparent geometry. A net name does not establish a safe voltage or current rating. Print voltage range, maximum current and other limits only with approved design evidence; distinguish input rating, rail nominal voltage and absolute maximum.+ Every test point MUST have visible silkscreen identifying both its reference and verified net/signal or measurement function. Prioritize these labels before ordinary component values. Keep them adjacent to the accessible probe pad, or use a short unambiguous leader/key on the same accessible face when crowded. Check complete test-point coverage against the actual board; missing or ambiguous labels are unresolved findings, never silently omitted. Repeat on the opposite face when useful for mounted-board debugging, without implying a probe pad exists there.++4. Use approximately 1.2–2.0 mm for board identity and critical connection labels, 0.8 mm for references and 0.5 mm for values/secondary notes under the named InstaPCB profile. These are starting sizes, not mandatory packing rules. Prefer horizontal text and consistent reading directions; rotate to follow a connector only when that aids use. Use familiar engineering notation (10k, 100nF, 4.7uF); distinguish value, tolerance and voltage rating. Give every resistor/capacitor its reference plus a nearby value; search microtext placements before declaring a space constraint. Long IC MPNs may belong in a back-side key rather than in congested assembly space.+Keep each reference unmistakably associated with its own component. Prefer reducing reference font size locally over moving a label farther away. Aim for complete reference coverage; use a short clear leader only when proximity alone is ambiguous. Treat approximately 0.8 mm as an initial reference size, not a minimum. For dense InstaPCB artwork Adam explicitly permits secondary values at 0.3 mm or even 0.2 mm (2026-09-16); try 0.5, 0.3 then 0.2 mm while preserving the ref/value pairing and required stroke/spacing. These tiny sizes are user-requested artwork options, not independently verified laser-process capability. Keep the actual sizes and any unresolved physical legibility/DFM limits in the review; do not silently omit labels or globally weaken fab rules. Inspect the result at actual size and close-up.++5. For every machine pin, machine contact and edge-pin connector, repeat its reference/pin number and verified signal or power function on BOTH faces. A mounted board may expose only one side during debugging. Put the repeated labels beside the same physical connection where possible; when crowded, use a short clear leader to the actual connection on that face; a remote keyed legend is supplementary only. A pinout table on the other face alone does not satisfy this check. Review both faces in the mounted-access context, with bottom text correctly mirrored and pin numbering preserved. Use both F.SilkS and B.SilkS (or the EDA's native equivalents). Bottom text must read correctly when viewed from underneath, with the EDA's proper mirror setting; do not reverse the string. Put a clear pinout/service key on the less crowded face, mapped to reference and pad number. Copper/pour labels identify a verified net; avoid implying that hidden traces are visible or electrically isolated.+6. Protect exposed pads, solder-mask openings, test contacts, holes, board edges, fiducials, optical windows, component courtyards and mechanical interfaces. Consider visible space with components fitted: body footprints may obscure text even when DRC passes. Retain assembly-only markings on fabrication layers if useful, but do not count them as visible silkscreen. Never move copper or parts merely to force extra text without a recorded design return.+7. Inspect both faces at realistic physical scale and enlarged, in native 2D, native assembled 3D and fabrication plots. Check overlaps, legibility, bottom mirroring, ref/value association, clip-to-mask losses and labels covered by components. Run native DRC with the selected fab profile; compare new violations to the baseline. Keep manufacturing uncertainties explicit.+8. Save the label manifest, before/after plots, native review images and DRC comparison. Prove connectivity, placement, zones, model transforms and board outline unchanged for a silk-only edit. Record unresolved labels rather than inventing them.++## Flow and bridge ownership++AI Flow orchestrates this as a `silkscreen` step and records review evidence. Native text insertion, layer/mirror settings, font metrics, visibility, plotting and DRC belong to the EDA bridge. Discover current verbs; request missing reusable operations from the owning bridge. An offline board-copy script is a transparent fallback, not a new competing bridge API. Do not use mouse clicks in KiCad workflows that prohibit them.++Film a slow top/bottom overview and a brief connector-label close-up in the native EDA. Keep raw recordings, then budget roughly 3–5 seconds in the final two-minute film; publish detailed readable plots separately. A render proves appearance, not laser-process qualification.++## Record the silkscreen being built++Start the native editor window recording BEFORE the first label mutation. Show references and their smaller value labels appearing one at a time, or in small meaningful groups chosen by the AI (a ref/value pair, a connector pinout, or a local circuit block). Keep enough dwell for the actual recorder to capture each change; inspect the contact sheet instead of assuming a fixed delay guarantees a frame. Frame the active region so text and its component remain visible, with occasional whole-board context and a face change for bottom markings.++Keep the recording running through actual revision: moves, rotations, font reductions, value pairing, overlap fixes and rejected placements should be visible in their real order. Preserve native undo and stable item identifiers where the bridge supports them. Save a sidecar event list with timestamps, affected references/IDs, operation, old/new text/position/size and a concise reason. Record explicit reasons and actions, not private chain-of-thought. Reusable add/update/delete text, refresh and undo operations belong in the EDA bridge; AI Flow chooses the sequence, records evidence and composes the result. Do not invent an unsupported bridge command or silently replace the entire board for each label.++If incremental native editing is unavailable, report that bridge gap and retain honest intermediate saved-board checkpoints and before/after evidence. A reconstruction from checkpoints or a reveal of finished labels MUST be identified as a replay; it is not footage of the original placement or reasoning. Do not manufacture rework to make the film interesting. The existing ESC v16 top/bottom review is final-state evidence, not a progressive-placement recording.++Retain the full raw, uncaptioned step clip and offer a separate detailed action cut that shows population and real rework. In the final two-minute video use roughly 3–5 seconds of accelerated population, including a representative correction when one occurred, ending on the final labelled board. Keep exact step/run clocks and chronological provenance. Put explanations in the composed narration/captions, never in the raw clip. Finish with native top/bottom inspection and DRC; a pleasing animation does not establish label coverage or fabrication legibility.++## Contact labels must preserve physical association on both faces++Place every machine-contact, machine-pin and edge-connector label beside its actual physical connection on BOTH faces, not merely in a remote pinout table. Treat a table as supplementary reference only; it never satisfies positional labelling. Verify each face against pad coordinates and numbering, including bottom mirroring. Record unresolved space constraints rather than claiming table coverage completes this requirement.++Separate the primary reference (for example MC10) from the secondary verified function (DSHOT). Use independently sized native text items: the function is smaller than the reference, allowing the pair to stay near the contact. Keep the pair visually grouped and readable in one direction; prefer consistent horizontal rows along a dense contact bank over alternating rotations that obscure association. Reduce size locally when necessary under the selected fabrication profile.++When proximity is still ambiguous, draw a short curved silkscreen leader from the label group toward its particular contact. Use a gentle arc or rounded path with an unmistakable endpoint outside exposed copper and solder-mask openings. Do not run a leader through another label, contact, part body, hole, board edge or another leader; keep clearance from unrelated silk. Avoid ornamental curves and crossings. Inspect both faces in native 2D and assembled 3D at contact-bank close-up scale, checking that each label and each leader points to exactly one intended connection. Native DRC remains required; a leader must not become clipped silk or resemble an electrical trace in the documentation.++Film real leader placement and ref/function resizing as part of progressive silkscreen capture. Native text, curves and undo operations belong in the EDA bridge; AI Flow owns the guidance, coverage checks, recording and composition. Retain source-to-pad mapping and unresolved physical font/stroke limits in the manifest.++## Complete local value coverage++For the requested InstaPCB profile, attempt a nearby value for EVERY resistor and capacitor, including rotated components and references. Search both orientations and adjacent sides at 0.5, 0.3 and 0.2 mm as needed, preserving an unmistakable reference/value association. Do not skip values merely because the reference is rotated, an initial placement fails, or a bottom table exists. Repack nearby silk or use a clear short leader when necessary. Audit actual-board value coverage and report each unresolved value explicitly; a back-side key is supplementary, not completion. Retain native mask/overlap checks and distinguish requested artwork sizes from measured physical legibility.++## Keep both native views current after every update++After EVERY board, footprint, silkscreen, library-binding or 3D-model update, update BOTH the native 2D board editor and its associated 3D viewer before reporting or showing the result. A file write, successful transfer, DRC result or web preview is not a refreshed native view. Verify the exact saved board path/revision in the editor, then regenerate/reload the 3D view and inspect the changed features after painting settles. Capture evidence from both exact windows; confirm models, markings and layer visibility, not only the window titles.++Use supported native refresh/reload commands through the owning EDA bridge. If an offline edit or model cache requires closing and reopening, inspect unsaved changes and dialogs first, preserve user work, close only task-owned stale windows, reopen the latest board, and open its linked 3D viewer. Never save stale editor contents over a newer disk revision. Keep one current editor/viewer pair rather than accumulating old windows. Re-discover HWNDs after reopening; preserve the user's foreground and view preferences unless showing a view was requested. Refresh a completed edit or coherent batch promptly; do not wait until the final video. If either view cannot be verified, state which one remains stale and resolve it before claiming the update is shown.++## Search space before shrinking text++Treat 0.2 mm as an exceptional fallback, not a successful default. Start with the largest sensible font for the information hierarchy and search nearby positions, rotations and both sides of the component. Use the native EDA's stroke-font bounds, stroke width, justification, rotation and mirrored bottom-face transform. If only estimated bounds are available, identify them as estimates and validate every accepted batch with native plots/DRC and assembled 3D visibility.++Build obstacles from fitted component bodies, individual pads and solder-mask openings, vias/holes, board edges, existing silk and text. Do not replace a collection of separated obstacles with one enormous bounding box that discards useful gaps. Keep reference/value pairs associated but retain separate text rectangles so empty space between them remains usable. Solve each face with its own obstacles; a copied top layout is not a validated bottom layout.++Search alternative positions at each useful font size, then jointly repack neighboring label groups. Use a bounded search with backtracking or beam alternatives so an early label does not permanently consume the best space. Favor complete coverage, readable font sizes and short unmistakable association; preserve connector pin-row order. Reserve and validate curved leader paths too. Report unresolved labels rather than hiding or silently omitting them. Never move copper or components merely to fit text without a design return.++Use `adom-aiflow silkscreen-layout --input candidates.json --out layout.json --ai-thread <thread> --run <run>` for the shared bounding-box candidate search. Input carries board bounds, fixed obstacle rectangles and label groups with alternative native-derived rectangles, font sizes, positions and anchors. A candidate may provide several `boxes` for an intact ref/value pair or leader segments. This is a bounded weighted search, not proof of a global optimum; it does not edit the board. Native text measurement/insertion/refresh remains the EDA bridge's job. Apply by stable item identity, never by text alone: duplicate values and repeated pinout tables are common.++Run native clearance/overlap checks, compare findings against the inherited baseline, then inspect actual-size legibility and close-up association on both faces. Revise the candidates when validation rejects them; do not just call the search successful. Refresh both the 2D editor and its linked 3D viewer after an accepted batch. Record genuine placement and rework in the silkscreen step clip.++### Candidate geometry evidence++`tools/native-text-bounds.py --svg front.svg --labels labels.json --layer F.SilkS --out bounds.json` measures native stroke paths from a single-face KiCad SVG export. Export each face separately through the bridge. The label manifest contains `labels` with `text`, `x`, `y`, `size`, `angle` and `layer`. The helper reports its nearest-text matching method and SVG hash; inspect ambiguous duplicate/multiline mappings. It is a fallback geometry reader, not a native live text-metrics API. Preserve stable item IDs for application.++A `silkscreen-layout` input has `bounds: [xmin,ymin,xmax,ymax]`, `obstacles: [{box: [...]}]`, and `labels: [{id,anchor:[x,y],candidates:[{font,position:[x,y],box:[...]}]}]`. Optional candidate `boxes` describes separate rectangles for grouped text/leader segments; every rectangle must be clear. Optional `orderGroup` and numeric `order` preserve increasing connector-row Y. `beamWidth` and `candidatesPerLabel` bound the search. The score weights font size, distance and movement; it cannot promise the global optimum. All outputs retain `nativeVerified: false` until separate native review evidence is recorded.++For tightly coupled regions, set `solver: "milp"` to use SciPy/HiGHS mixed-integer selection with `solverSeconds`, `milpPerFont`, `preferredMinimumFont`, and `smallFontPenalty`. This optional engine needs SciPy; the default beam engine uses Python's standard library. Candidate pruning must preserve alternatives at every font size. Solver optimality only concerns the finite supplied candidate set, never all possible board artwork.++`tools/silkscreen-leaders.py --input candidates.json --out routed-candidates.json` accepts per-label `leaderTargets` and `routeBounds`, uses a bounded grid search around fixed rectangles, and appends leader segment bounds to each candidate. The final selection prevents those reserved paths crossing other selected text/routes. It retains alternatives per font size. Native application may gently round corners inside the cleared envelope; validate the actual resulting curves. Failure to route a leader must trigger repacking or an explicit unresolved finding.++### Complete obstacle coverage is required++Before placing or accepting silkscreen, enumerate every fitted model, via and drilled hole across the entire board, on both faces. Record coverage counts and stable IDs. A local search region does not excuse leaving the rest of the board unchecked. Project the actual fitted 3D model into the board plane with its scale, offset, rotation and footprint transform; include overhangs. Conservative transformed model bounds are acceptable when a tighter silhouette is unavailable. F.Fab, courtyard or pad envelopes are fallbacks, not proof of fitted-body coverage: a footprint can have no Fab drawing, and the space between separated pads can still be occupied by the body. Missing model geometry must be reported and supplied a documented conservative envelope, never a tiny placeholder that silently allows text beneath the component.++Reserve every via hole on both faces, including tented vias when visible labeling is the requirement, and use the larger of the hole keepout and actual exposed mask opening. Also reserve through holes, slots, exposed pads and existing silk. Check actual stroked text extents, not just its anchor. After each re-layout, audit ALL existing labels and leaders against this complete obstacle set. A collision-free position is not enough: keep each reference/value next to its own component or add a clear, noncrossing leader. Do not move a label beside a different component merely to clear an obstacle. Native DRC and fitted 3D visibility are separate acceptance checks; a DRC baseline does not establish readable assembled silkscreen. Refresh and inspect both native 2D and 3D views before presenting the result.++## Optional live silkscreen dashboard++Offer `silkscreen-dashboard show` with the run and ai-thread flags. The shared observer renders real timestamped solver events, sampled candidate bounds and reasons, selected proposals and unresolved labels, in 2D and a fitted-model 3D preview. `silkscreen-layout --events <run>/silkscreen-dashboard/events.jsonl` emits these without AI/provider calls or deliberate solver pauses. Read `tools/silkscreen-dashboard/SKILL.md` for the manifest, transform limits, lifecycle and recording contract. Live and replay are visibly distinct; replay exports are not raw native CAD footage. Selected proposals remain unverified until native application, DRC and assembled visibility checks pass. Fusion/Altium adapters are not implemented merely because the manifest is tool-neutral.+@@ -0,0 +1,10 @@+# Watch silkscreen placement++The optional AI Flow silkscreen dashboard displays live solver events or replays their saved history. Users can inspect sampled candidates, selected reference/value pairs and unresolved groups in 2D, then inspect fitted component geometry in 3D. It makes no AI/provider calls. Rendering and recording have CPU/GPU/storage costs; no token multiplier is assumed.++Start/show with `adom-aiflow silkscreen-dashboard show --ai-thread "<name>" --run <run>`. Prepare the explicit manifest described in `tools/silkscreen-dashboard/SKILL.md`; the ESC's exporter is currently a task adapter, not an automatic cross-EDA importer. Native bridge ownership is documented in KiCad Bridge issue #104.++Use `silkscreen-layout --events <run>/silkscreen-dashboard/events.jsonl` to emit real timestamped solver decisions. The dashboard samples candidates and explains constraints; it does not expose private reasoning or fabricate intermediate edits. Opt out by omitting `--events` and not starting the dashboard. Solver correctness does not depend on visualization.++Live/replay status and native-review-pending status remain visible. Record replay produces a labelled 2D WebM; retain the detailed walkthrough separately from the short final-video segment. No replay is presented as live native CAD footage. KiCad, Fusion and Altium bridges should supply native geometry/metrics, edits, undo, validation and linked view refresh; the dashboard and search stay shared. Only the KiCad ESC data adapter has been exercised so far.+@@ -1,1094 +1,1128 @@⋯ 25 unchanged lines ⋯ #[derive(Subcommand)] enum Cmd {+ SilkscreenLayout { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] events: Option<PathBuf> },+ SilkscreenDashboard { action: String, #[arg(long, default_value="auto")] port: String, #[arg(long, default_value="wv")] surface: String, #[arg(long)] url: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] json: Option<String>, #[arg(long)] reason: Option<String> },+ SilkscreenAudit { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },+ /// Start a run: copies the board, stamps the prompt time Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool }, /// The stages this board needs and who can take each⋯ 438 unchanged lines ⋯ } } match &cli.cmd {+ Cmd::SilkscreenAudit { input, out } => {+ thread(&cli); let _r = load_run(&cli);+ let result = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/silkscreen-obstacle-check.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e| err(&format!("silkscreen audit needs python3: {e}"), &[]));+ if !result.status.success() { err(&String::from_utf8_lossy(&result.stdout), &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views.".into()]); }+ ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required.".into()]);+ }+ Cmd::SilkscreenDashboard { action, port, surface, url, target, json, reason } => {+ let t = thread(&cli); let _r = load_run(&cli);+ let mut command = std::process::Command::new("adom-aiflow-silkscreen");+ command.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t).arg("--port").arg(port).arg("--surface").arg(surface);+ if let Some(v)=url { command.arg("--url").arg(v); }+ if let Some(v)=target { command.arg("--target").arg(v); }+ if let Some(v)=json { command.arg("--json").arg(v); }+ if let Some(v)=reason { command.arg("--reason").arg(v); }+ let result=command.output().unwrap_or_else(|e|err(&format!("silkscreen dashboard helper missing: {e}"), &["Install the matching AI Flow package helper.".into()]));+ if !result.status.success() {err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &[]);}+ ok(&String::from_utf8_lossy(&result.stdout), &["Live feed and replay visualize recorded solver events. Proposed labels do not modify the native board.".into()]);+ }+ Cmd::SilkscreenLayout { input, out, events } => {+ thread(&cli);+ let _r = load_run(&cli);+ let source = include_str!("../../../tools/silkscreen-layout.py");+ let mut command = std::process::Command::new("python3");+ command.arg("-c").arg(source).arg("--input").arg(input).arg("--out").arg(out);+ if let Some(v)=events {command.arg("--events").arg(v);}+ let result = command.output().unwrap_or_else(|e| err(&format!("silkscreen planner needs python3: {e}"), &[]));+ if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }+ ok(&String::from_utf8_lossy(&result.stdout), &["Candidate rectangles only: verify native text bounds, component-body visibility and DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]);+ } Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force } => { let t = thread(&cli); if !Path::new(board).is_file() {⋯ 616 unchanged lines ⋯ } } }+@@ -1,83 +1,88 @@⋯ 80 unchanged lines ⋯ - `finish` is the only thing that ends a run. A run without `finish` is not a result, and its minutes are still counting. - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not). - 0.1's analyses are conservative heuristics (IPC-2221 for tracks, presence, connection style and vias for pours and tabs); they say so in their output. 0.2 computes cross-sections through the filled copper.++## Silkscreen placement, complete coverage and observable decisions++Read `docs/silkscreen.md` for complete coverage, font/locality search, both-face contact/test-point labels, values and leader rules. Read `tools/silkscreen-dashboard/SKILL.md` for optional live/replay observation and recordings. Native geometry, edit/undo and 2D/3D refresh remain bridge responsibilities. `silkscreen-layout` proposes; `silkscreen-audit` checks an independent whole-board inventory; neither claims native acceptance. The dashboard makes no AI/provider calls. Fusion and Altium data adapters still need implementation and native tests.+@@ -1,83 +1,88 @@⋯ 80 unchanged lines ⋯ - `finish` is the only thing that ends a run. A run without `finish` is not a result, and its minutes are still counting. - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not). - 0.1's analyses are conservative heuristics (IPC-2221 for tracks, presence, connection style and vias for pours and tabs); they say so in their output. 0.2 computes cross-sections through the filled copper.++## Silkscreen placement, complete coverage and observable decisions++Read `docs/silkscreen.md` for complete coverage, font/locality search, both-face contact/test-point labels, values and leader rules. Read `tools/silkscreen-dashboard/SKILL.md` for optional live/replay observation and recordings. Native geometry, edit/undo and 2D/3D refresh remain bridge responsibilities. `silkscreen-layout` proposes; `silkscreen-audit` checks an independent whole-board inventory; neither claims native acceptance. The dashboard makes no AI/provider calls. Fusion and Altium data adapters still need implementation and native tests.+@@ -1,13 +1,17 @@⋯ 10 unchanged lines ⋯ command -v service-kicad >/dev/null 2>&1 || echo "Hint: the offline gate needs service-kicad (adom-wiki pkg install adom/service-kicad)" command -v adom-bridge >/dev/null 2>&1 || echo "Hint: the live stages need adom-bridge (the Adom Bridge CLI) and the KiCad Bridge on a desktop" echo "OK: adom-aiflow installed at ~/.local/bin/adom-aiflow. Run 'adom-aiflow --version'."++chmod +x "$HERE/tools/silkscreen-dashboard/server.py"+ln -sf "$HERE/tools/silkscreen-dashboard/server.py" "$HOME/.local/bin/adom-aiflow-silkscreen"+@@ -1,162 +1,183 @@⋯ 85 unchanged lines ⋯ "record": "the Adom Fields window: the board in 3D drifting gently through each beat, the heat on the top copper, each pour isolated on its own, the board turned over to the bottom, the temperature, then an issue or two; slow on purpose, the final video plays it at ten times speed" }, {+ "name": "silkscreen",+ "who": "ai",+ "does": "Make both faces useful in real service: references and values, verified connector and machine-contact pinouts, polarity, board identity and bring-up labels; review native plots and assembled visibility. Offer the optional shared silkscreen dashboard: real solver events, candidate reasons, live/replay separation, fitted-model inspection and separately recorded detailed/5s replays. Audit every fitted body and every via/hole across the whole board, both faces, with coverage counts. Missing Fab geometry is not free space; native DRC does not establish fitted visibility. Keep unresolved labels explicit and do not apply an incomplete layout. Native geometry/edit/undo/refresh belongs to the EDA bridge.",+ "workflow": [+ "Read the InstaPCB silkscreen skill for the selected process; reserve label space during placement and finalize after copper and analysis stabilize. Use both faces, a clear font-size hierarchy, and the approved process profile for small secondary text.",+ "Build a source-backed label manifest from actual schematic, pad numbers, nets and approved requirements. Include reference/value pairs, connector pinouts, power polarity, test points, switch/LED functions, revision and documentation link. Never infer voltage/current ratings from net names or component absolute maxima.",+ "For every machine pin, machine contact and edge-pin connector, repeat its reference/pin number and verified signal or power function on BOTH faces. A mounted board may expose only one side during debugging. Put the repeated labels beside the same physical connection where possible; when crowded, use a short clear leader to the actual connection on that face; a remote keyed legend is supplementary only. A pinout table on the other face alone does not satisfy this check. Review both faces in the mounted-access context, with bottom text correctly mirrored and pin numbering preserved.",+ "Every test point MUST have visible silkscreen identifying both its reference and verified net/signal or measurement function. Prioritize these labels before ordinary component values. Keep them adjacent to the accessible probe pad, or use a short unambiguous leader/key on the same accessible face when crowded. Check complete test-point coverage against the actual board; missing or ambiguous labels are unresolved findings, never silently omitted. Repeat on the opposite face when useful for mounted-board debugging, without implying a probe pad exists there.",+ "Keep each reference unmistakably associated with its own component. Prefer reducing reference font size locally over moving a label farther away. Aim for complete reference coverage; use a short clear leader only when proximity alone is ambiguous. Treat approximately 0.8 mm as an initial reference size, not a minimum. For dense InstaPCB artwork Adam explicitly permits secondary values at 0.3 mm or even 0.2 mm (2026-09-16); try 0.5, 0.3 then 0.2 mm while preserving the ref/value pairing and required stroke/spacing. These tiny sizes are user-requested artwork options, not independently verified laser-process capability. Keep the actual sizes and any unresolved physical legibility/DFM limits in the review; do not silently omit labels or globally weaken fab rules. Inspect the result at actual size and close-up.",+ "Avoid mask openings, contact surfaces, holes, fiducials and bodies that hide labels. Inspect bottom mirroring, actual-size legibility and both native 2D/3D faces. Use EDA bridge text/plot/DRC operations; give missing primitives back to the bridge.",+ "Run native DRC against the chosen fab profile and compare with the baseline. Preserve connectivity, placement, copper, outline and model transforms for silk-only edits. Register the label manifest and top/bottom evidence; return here when placements or pinouts change.",+ "Record BEFORE the first silkscreen mutation: show labels appearing individually or in small meaningful groups, pairing references with smaller values. Film actual moves, resizing, rotations and overlap corrections in order; preserve a timestamped operation/reason sidecar and raw uncaptioned footage. Use native bridge edits and refresh, never invented verbs. If native incremental editing is missing, file the bridge gap; identify any checkpoint reconstruction as a replay, never as original live placement. Keep a detailed action cut and use 3\u20135 seconds of accelerated population/rework in the final 120-second film. Read docs/silkscreen.md for recording and evidence rules.",+ "Require positional contact labels on BOTH faces: a pinout table is supplementary, never a substitute for text beside each actual machine contact, machine pin or edge connection. Separate primary reference (MC10) and smaller secondary function (DSHOT) as independently sized paired text. Prefer consistent reading directions. Where association remains ambiguous, add a short gentle curved silkscreen leader ending outside the intended pad mask opening; avoid crossings and obstacles. Verify one-to-one pad association, bottom mirroring, label/leader clearance and legibility in close-up native views and DRC. Film real additions and rework; retain mapping and unresolved constraints. See docs/silkscreen.md.",+ "For the requested InstaPCB profile, attempt a nearby value for EVERY resistor and capacitor, including rotated components and references. Search both orientations and adjacent sides at 0.5, 0.3 and 0.2 mm as needed, preserving an unmistakable reference/value association. Do not skip values merely because the reference is rotated, an initial placement fails, or a bottom table exists. Repack nearby silk or use a clear short leader when necessary. Audit actual-board value coverage and report each unresolved value explicitly; a back-side key is supplementary, not completion. Retain native mask/overlap checks and distinguish requested artwork sizes from measured physical legibility.",+ "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences.",+ "Search space before shrinking text: 0.2 mm is a last resort. Use native text bounds and per-face body/pad/mask/via/silk obstacles, keep separate boxes for ref/value pairs, repack neighboring labels, preserve pin-row order and validate leader paths. Run silkscreen-layout on candidates; bounded search is not native verification. Apply by stable item ID, compare native DRC with baseline, inspect fitted visibility, and refresh BOTH native views. See docs/silkscreen.md."+ ],+ "record": "Native incremental label population on both faces, individual ref/value pairs or meaningful groups, plus real rework. Raw uncaptioned clip and timestamped edit sidecar; detailed action cut separate from the 3\u20135 second final-video excerpt. Clearly label reconstructed replay."+ },+ { "name": "nets", "who": "binary", "does": "walk the key nets on camera: each loaded, wide or Kelvin net selected as a whole so the editor highlights its pours, tracks, vias and pads together, framed with Zoom to Selection",⋯ 68 unchanged lines ⋯ "clips": "every `step <name>` stops the previous step's clip and starts a new window recording tagged with the step, when the board is open on a test box; run.json captures[] carries one entry per clip with its step, start, stop and file, and deliver lists them; the final video is cut from these clips, one segment per step, so two engines' videos line up step for step; a return (step <name> --back --why) is a new visit and gets its own clip, tagged <step>-<visit> with the reason, so the rework is on camera and the final cut can show the loop", "screenshots": "every step visit gets two background screenshots of the editor window, at its start and at its end (shot-<step>-<visit>-start.png, shot-<step>-<visit>-end.png), logged as artifacts, so a run's own README has a picture for every step without anyone taking one" }+
Comments
Log in to comment.
The focused implementation is now PR #17, built against a fresh source clone: https://wiki.adom.inc/adom/adom-aiflow/prs/17 . CLI-driven recording and automatic run artifact retention are the tested companion PR #18: https://wiki.adom.inc/adom/adom-aiflow/prs/18 . Please integrate overlapping silk guidance once and preserve the separate component-library/hero changes in the earlier series. Source handoff is not a release; reply with an insiders version once built/published so I can verify deployment.