← All Pull Requests

Automate silkscreen dashboard recording and retain clips in the run #18

Merged opened by John Lauer 2026-09-17

Companion to AI Flow PR #17: allow automated per-run dashboard recording without a manual browser download.

adom-aiflow silkscreen-dashboard control --json '{"record":"overview"}' --run <run> --ai-thread <name> now drives the same recorder as the UI through SSE. Use walkthrough for the detailed replay. The browser posts the completed WebM back to the run; /state.lastRecording returns the file, URL, kind, byte count and explicit replay flag. Empty/invalid WebM uploads are refused. The record request is cleared after completion, preventing a page reload from re-recording an already completed request.

Adds per-run/thread process locking and a bounded same-port restart retry. These address lifecycle races observed while testing the task-owned server. No shared desktop bridge is restarted.

Verified with the freshly built #17 Rust CLI, an actual live Hydrogen webview, and an automatic overview upload: /home/adom/aiflow-esc-astra/silkscreen-dashboard/recordings/replay-1789648186846888253.webm (510823 bytes). Both detailed and overview exports were decoded; the published H.264 walkthrough is 76.50s / 6.9 MB. Board acceptance remains pending, as in #17.

Merge after #17; only the helper, UI and companion skill change. Rebuild/package the helper assets with the release; a Rust rebuild alone does not deploy updated HTML/Python files.

Diff Skip to comments

--- a/tools/silkscreen-dashboard/server.py+++ b/tools/silkscreen-dashboard/server.py@@ -0,0 +1,166 @@+#!/usr/bin/env python3+"""AI Flow silkscreen observer: persisted solver events, no provider calls or CAD edits."""+import argparse,base64,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('/recordings/'):+   f=(folder/path.lstrip('/')).resolve()+   if not f.is_relative_to((folder/'recordings').resolve()) or not f.is_file():return self.response({'error':'not found'},404)+   return self.response(f.read_bytes(),ctype='video/webm')+  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'))>(40_000_000 if self.path=='/recording' else 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=='/recording':+     video=base64.b64decode(body['video'],validate=True)+     if len(video)<1024 or video[:4]!=bytes.fromhex('1a45dfa3'):raise ValueError('Refused empty or invalid WebM recording')+     clips=folder/'recordings';clips.mkdir(exist_ok=True);name='replay-'+str(time.time_ns())+'.webm';(clips/name).write_bytes(video);state['lastRecording']={'file':str(clips/name),'url':'recordings/'+name,'kind':body.get('kind','walkthrough'),'bytes':len(video),'replay':True};state['recordRequest']=None;save();broadcast();self.response({'ok':True,'recording':state['lastRecording']});return+    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=='record' and v in ['overview','walkthrough']:state['recordRequest']={'id':time.time_ns(),'kind':v}+      elif 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,KeyError) as ex:self.response({'error':str(ex)},400)+http.server.ThreadingHTTPServer.allow_reuse_address=True+import fcntl+lease=open(REG/(key+'.lock'),'w')+try:fcntl.flock(lease,fcntl.LOCK_EX|fcntl.LOCK_NB)+except BlockingIOError:raise SystemExit('OK: another instance for this run/thread is starting or running')+for attempt in range(30):+ try:+  server=http.server.ThreadingHTTPServer(('127.0.0.1',0 if a.port=='auto' else int(a.port)),Handler);break+ except OSError:+  if attempt==29:raise+  time.sleep(.1)+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)+--- a/tools/silkscreen-dashboard/index.html+++ b/tools/silkscreen-dashboard/index.html@@ -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,handledRecording=null,recordReplay;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();if(state.recordRequest&&state.recordRequest.id!==handledRecording){handledRecording=state.recordRequest.id;setTimeout(()=>recordReplay(state.recordRequest.kind==='overview').catch(e=>$('#error').textContent=e.message),0)}}+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;+recordReplay=async function(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;const reader=new FileReader();reader.onload=()=>api('recording',{kind:summary?'overview':'walkthrough',video:reader.result.split(',')[1]}).catch(e=>$('#error').textContent=e.message);reader.readAsDataURL(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>+--- a/tools/silkscreen-dashboard/SKILL.md+++ b/tools/silkscreen-dashboard/SKILL.md@@ -0,0 +1,28 @@+# 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.++## Automated recording++Use `adom-aiflow silkscreen-dashboard control --json '{"record":"overview"}' --run <run> --ai-thread "<name>"` (or `walkthrough`). An open dashboard records through the same UI path and uploads the completed WebM into `<run>/silkscreen-dashboard/recordings/`; read `state.lastRecording` for the path. A queued request is not a completed clip. Verify nonempty decoded frames and visible replay/native-review status. Normalize WebM with ffmpeg `fps=30` before H.264 composition; MediaRecorder can report a 1000 Hz nominal rate. Keep each wiki asset below its cap.+

Comments

No comments yet.

Log in to comment.