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.
Preserve silkscreen text groups, alignment and consistent contact borders #20
Silkscreen placement lost reference/value grouping and scattered documentation lines into a center column. The dashboard also centered its font inside native text bounds, obscuring intended alignment. This patch preserves semantic sections, aligns documentation to its readable left edge and pin labels toward their pad, and gives repeated contact columns common frame rails and row heights. Frames are optional, on by default; edge-side placement is preferred. ESC phase terminals receive explicit importance guidance.
Includes complete KiCad custom-pad mask geometry (not only the anchor rectangle), independent polygon preflight, paired label constraints, STRtree/MILP collision search, contact policy and section-layout commands, dashed translucent calculation guides, bottom-face projection/cursor zoom, and the standard settings cog/dialog. The reusable geometry helpers remain Python invoked by the Rust CLI. This is a development implementation, not a claim of a pure Rust or released feature.
Validation: rebuilt the Rust release binary; ran layout, custom-mask, contact-policy and section-order regression tests; exercised the new section CLI and native preflight. Fresh ESC v35 includes 437 authored labels / 463 native text items and 91 association graphics. Preflight reports zero collisions. Native KiCad 10.0.5 returns the same 13 inherited errors and 539 warnings as the baseline, with no new silkscreen violations. Checked the live Hydrogen rendering and native top/bottom views. The contact-column solver uses common rails and 1.8 mm row height on both faces, keeping clearance gaps explicit.
Draft companion/successor to #17, #18 and #19; reconcile those before merging this file set. Five component/pin association reviews remain, and non-KiCad adapters, production packaging/dependency handling and broader board diversity testing remain release work. The ESC adapter scripts live in /home/adom/aiflow-esc-astra/silkscreen; generalize their inventory/metric export rather than claiming all adapters are complete. Do not auto-publish this draft as a finished feature.
Diff Skip to comments (1)
@@ -1,1094 +1,1158 @@⋯ 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 },+ SilkscreenSections { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },+ SilkscreenPreflight { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },+ SilkscreenMask { #[arg(long)] board: PathBuf, #[arg(long)] out: PathBuf },+ SilkscreenContactPolicy { #[arg(long)] input: PathBuf, #[arg(long)] geometry: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] no_contact_borders: bool },+ /// 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::SilkscreenSections { input, out } => {+ thread(&cli); let _r=load_run(&cli);+ let result=std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/silkscreen-sections.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("section layout could not run: {e}"), &[]));+ if !result.status.success(){err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &["Keep each documentation section together. Change its region, column layout or font; never scatter its rows to claim full coverage.".into()]);}+ ok(&String::from_utf8_lossy(&result.stdout), &["Preserve headings, ordered rows, common alignment and spacing. Tables supplement physical terminal labels. Review both readable faces and native DRC before acceptance.".into()]);+ }+ Cmd::SilkscreenPreflight { input, out } => {+ thread(&cli); let _r=load_run(&cli);+ let result=std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/silkscreen-preflight.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("preflight could not run: {e}"), &[]));+ if !result.status.success(){err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &["Repack every reported collision before transferring to the EDA. Include full custom-pad mask geometry, fitted bodies, holes and footprint artwork.".into()]);}+ ok(&String::from_utf8_lossy(&result.stdout), &["Preflight passed; confirm with native DRC and inspect both faces. Association quality remains a separate visual gate.".into()]);+ }+ Cmd::SilkscreenMask { board, out } => {+ thread(&cli); let _r=load_run(&cli);+ let result=std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/silkscreen-kicad-mask.py")).arg("--board").arg(board).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("mask export could not run: {e}"), &[]));+ if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &["Unsupported mask geometry is a blocking preflight error, never an anchor-box fallback.".into()]);}+ ok(&String::from_utf8_lossy(&result.stdout), &["Use these complete native mask polygons with fitted body, hole and artwork obstacles.".into()]);+ }+ Cmd::SilkscreenContactPolicy { input, geometry, out, no_contact_borders } => {+ thread(&cli); let _r=load_run(&cli);+ let mut command=std::process::Command::new("python3");command.arg("-c").arg(include_str!("../../../tools/silkscreen-contact-policy.py")).arg("--input").arg(input).arg("--geometry").arg(geometry).arg("--out").arg(out);+ if *no_contact_borders{command.arg("--no-contact-borders");}+ let result=command.output().unwrap_or_else(|e|err(&format!("contact policy could not run: {e}"), &[]));+ if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &[]);}+ ok(&String::from_utf8_lossy(&result.stdout), &["Solve outside-first contact candidates; frames default on and reserve their stroke. Repeated connector columns share border rails and row height; record layout exceptions. Use --no-contact-borders to disable frames. Report inside fallbacks and clearance gaps.".into(), "Before layout, rank interfaces for this board. ESCs need prominent verified PHASE A/B/C, battery and ground labels; functional terminal names can outrank connector references.".into()]);+ }+ 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), &["Check reference/value completeness and hierarchy, outside-first ordered contacts and board-specific interface prominence. Run silkscreen-preflight before native 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 ⋯ } } }+@@ -0,0 +1,95 @@+#!/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)+ # Spatial broad phase keeps full-board grouped candidate sets practical.+ from shapely.geometry import box as geometry_box+ from shapely.strtree import STRtree+ valid=[i for i,(_,a) in enumerate(candidates) if not a.get('unresolved')]+ tree=STRtree([geometry_box(*candidates[i][1]['box']).buffer(gap) for i in valid])+ for i in valid:+ g,a=candidates[i]+ nearby={valid[int(k)] for k in tree.query(geometry_box(*a['box']))}+ if g.get('orderGroup'):+ nearby.update(j for j in valid if candidates[j][0].get('orderGroup')==g['orderGroup'])+ for j in nearby:+ if j>=i:continue+ h,b=candidates[j]+ if h['id']==g['id']: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 g.get('requiredRoles'):+ roles={x.get('role'):x for x in c.get('children',[])}+ if any(role not in roles for role in g['requiredRoles']):continue+ if 'reference' in roles and 'value' in roles and roles['value']['size']>roles['reference']['size']*g.get('maxValueRatio',.8):continue+ 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.get('qualityPenalty',0)+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,62 @@+#!/usr/bin/env python3+"""Repair a reviewed layout using atomic reference/value candidates.+Input: labels with native normalized offsets, board outline, fixed obstacle polygons,+component anchors and body polygons. Existing non-pair labels are reserved unchanged.+Prior positions are explicit candidates, not represented as a fresh solve.+"""+import argparse,json,math,importlib.util+from pathlib import Path+from collections import defaultdict+from shapely.geometry import shape,box,Point+from shapely.ops import unary_union+from shapely.prepared import prep++def generate(d):+ labels=d['labels'];outline=shape(d['outline']);pairs=defaultdict(list)+ for i,l in enumerate(labels):+ if l.get('reference') and not l.get('pin') and l['layer']=='F.SilkS' and l['reference'].startswith(('R','C')):pairs[l['reference']].append(i)+ pairs={r:ids for r,ids in pairs.items() if len(ids)==2 and any('value' in labels[i]['role'] for i in ids)}+ paired={i for ids in pairs.values() for i in ids}+ fixed=prep(unary_union([shape(o) for o in d['obstacles']]+([] if d.get('fresh') else [box(*l['box']) for i,l in enumerate(labels) if l['layer']=='F.SilkS' and i not in paired])))+ def bounds(i,size,angle):+ l=labels[i];o=l['offset'];a=math.radians(l.get('angle',0)-angle);ps=[((u*math.cos(a)-v*math.sin(a))*size,(u*math.sin(a)+v*math.cos(a))*size) for u,v in [(o[0],o[1]),(o[0],o[3]),(o[2],o[1]),(o[2],o[3])]];xs,ys=zip(*ps);return[min(xs),min(ys),max(xs),max(ys)]+ groups=[]+ for ref,ids in pairs.items():+ ids.sort(key=lambda i:'value' in labels[i]['role']);anchor=d['parts'][ref]['anchor'];body=shape(d['parts'][ref]['body']);cs=[]+ def candidate(children,origin):+ gs=[box(*c['box']) for c in children]+ if any(not outline.contains(g) or fixed.intersects(g) for g in gs) or gs[0].buffer(.02).intersects(gs[1]):return+ if any(g.distance(body)>1.5 for g in gs):return+ bb=unary_union(gs).bounds;center=[(bb[0]+bb[2])/2,(bb[1]+bb[3])/2];separation=gs[0].distance(gs[1]);fs,vs=[c['size'] for c in children]+ cs.append(dict(font=round(fs+vs*.2,4),position=center,box=list(bb),boxes=[c['box'] for c in children],children=children,origin=origin,qualityPenalty=separation*12+(0 if children[0]['angle']==children[1]['angle'] else 2)))+ def child(i,size,angle,x,y):+ b=bounds(i,size,angle);return dict(index=i,reference=ref,text=labels[i]['text'],role='value' if 'value' in labels[i]['role'] else 'reference',layer='F.SilkS',size=size,angle=angle,x=x,y=y,box=[b[0]+x,b[1]+y,b[2]+x,b[3]+y])+ # Reviewed locations remain a repair candidate only when hierarchy is satisfied.+ for fs in [.8,.7,.6,.5,.4,.35,.3,.25]:+ if fs<.25:continue+ for vs in [.4,.35,.3,.25,.2]:+ if vs>fs*.8:continue+ if not d.get('fresh'):candidate([child(i,s,labels[i].get('angle',0),labels[i]['x'],labels[i]['y']) for i,s in zip(ids,[fs,vs])],'reviewed-position repair')+ for angle in [0,90]:+ bs=[bounds(i,s,angle) for i,s in zip(ids,[fs,vs])]+ for horizontal in [False,True]:+ w=[b[2]-b[0] for b in bs];h=[b[3]-b[1] for b in bs]+ # Shared edge/alignment; secondary value always smaller and adjacent.+ centers=[(0,0),(0,(h[0]+h[1])/2+.08)] if not horizontal else [(0,0),((w[0]+w[1])/2+.10,0)]+ local=[(cx-(b[0]+b[2])/2,cy-(b[1]+b[3])/2) for (cx,cy),b in zip(centers,bs)]+ bb=unary_union([box(b[0]+x,b[1]+y,b[2]+x,b[3]+y) for b,(x,y) in zip(bs,local)]).bounds+ x0,y0,x1,y1=body.bounds+ for gap in [.06,.2,.45]:+ for shift in [0,-.25,.25,-.5,.5,-.8,.8]:+ positions=[(anchor[0]+shift-(bb[0]+bb[2])/2,y0-gap-bb[3]),(anchor[0]+shift-(bb[0]+bb[2])/2,y1+gap-bb[1]),(x0-gap-bb[2],anchor[1]+shift-(bb[1]+bb[3])/2),(x1+gap-bb[0],anchor[1]+shift-(bb[1]+bb[3])/2)]+ for x,y in positions:candidate([child(i,s,angle,x+dx,y+dy) for i,s,(dx,dy) in zip(ids,[fs,vs],local)],'joint pair search')+ # Keep spatial diversity so neighboring groups can negotiate room.+ buckets={}+ for c in cs:+ key=(c['font'],round(c['position'][0]*2),round(c['position'][1]*2));score=c['qualityPenalty']+math.dist(c['position'],anchor)+ if key not in buckets or score<buckets[key][0]:buckets[key]=(score,c)+ cs=[v[1] for v in buckets.values()];groups.append(dict(id=ref,anchor=anchor,candidates=cs,requiredRoles=['reference','value'],maxValueRatio=.8));print(ref,len(cs),flush=True)+ return dict(bounds=list(outline.bounds),labels=groups,clearance=.025,solver='milp',solverSeconds=60,milpPerFont=5,candidatesPerLabel=1000,fontWeight=20,distanceWeight=3)+if __name__=='__main__':+ p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--out',required=True);a=p.parse_args();Path(a.out).write_text(json.dumps(generate(json.loads(Path(a.input).read_text()))))+@@ -0,0 +1,47 @@+#!/usr/bin/env python3+"""Collision-checked visual associations for a selected silkscreen layout (mm).+Try an enclosure when requested, otherwise a curved pointer to the body's rim.+Never draw across text, pads, holes or another fitted body. Report failures.+"""+import json,argparse,math+from pathlib import Path+from shapely.geometry import shape,box,LineString,Point+from shapely.ops import unary_union,nearest_points+from shapely.prepared import prep++def associate(data):+ children=data['placements'];graphics=[];unresolved=[]+ for p in children:+ ref=p.get('reference');side=p['layer']+ if p.get('contactBorder'):+ for points in p['associationSegments']:graphics.append(dict(index=p['index'],reference=ref,layer=side,kind='outline',samples=points,contactBorder=True))+ continue+ if not ref or p.get('role')=='value':continue+ target=shape(data.get('targets',{}).get(str(p['index']),data['bodies'].get(side,{}).get(ref,{'type':'GeometryCollection','geometries':[]})))+ if target.is_empty:continue+ target=target.buffer(data.get('targetMargin',.02))+ own=unary_union([box(*q['box']) for q in children if q['layer']==side and q.get('reference')==ref and (not p.get('pin') or q['index']==p['index'])]);distance=own.distance(target)+ blockers=[shape(o['polygon']) for o in data['obstacles'] if o['layer']==side]+ blockers += [box(*q['box']).buffer(.025) for q in children if q['layer']==side]+ blockers += [LineString(g['samples']).buffer(.06) for g in graphics if g['layer']==side]+ blocked=prep(unary_union(blockers))+ if data.get('style')=='enclosure' and not p.get('pin'):+ members=[box(*q['box']) for q in children if q['layer']==side and q.get('reference')==ref]+ enclosure=box(*unary_union([target]+members).bounds).buffer(.15,join_style=2).exterior+ if not blocked.intersects(enclosure.buffer(.025)):+ samples=list(enclosure.coords);p['associationOutline']=samples;graphics.append(dict(index=p['index'],reference=ref,layer=side,kind='outline',samples=samples));continue+ if distance<.20:continue+ a0,b0=nearest_points(own,target);a0=list(a0.coords)[0];b0=list(b0.coords)[0];found=None+ # Route from the label edge to just outside the target, never across its mask.+ a=tuple(a0[j]+(b0[j]-a0[j])*.10/distance for j in [0,1]);b=tuple(b0[j]-(b0[j]-a0[j])*.10/distance for j in [0,1]);dx,dy=b[0]-a[0],b[1]-a[1];length=math.hypot(dx,dy)+ if length<=.08:continue+ for bend in [.12,-.12,.3,-.3,.6,-.6,1,-1,0]:+ c1=(a[0]+dx/3-dy/length*bend,a[1]+dy/3+dx/length*bend);c2=(a[0]+dx*2/3-dy/length*bend,a[1]+dy*2/3+dx/length*bend)+ points=[a,c1,c2,b];samples=[tuple((1-t)**3*a[j]+3*(1-t)**2*t*c1[j]+3*(1-t)*t*t*c2[j]+t**3*b[j] for j in [0,1]) for t in [i/40 for i in range(41)]]+ if blocked.intersects(LineString(samples).buffer(.035)):continue+ found=dict(index=p['index'],reference=ref,layer=side,kind='curve',points=points,samples=samples);p['leaderCurve']=points;graphics.append(found);break+ if found is None:unresolved.append(dict(index=p['index'],reference=ref,layer=side,distance=distance,reason='No collision-free direct curved pointer; review enclosure or reroute'))+ return dict(placements=children,graphics=graphics,unresolvedAssociations=unresolved)+if __name__=='__main__':+ p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--out',required=True);a=p.parse_args();r=associate(json.loads(Path(a.input).read_text()));Path(a.out).write_text(json.dumps(r,indent=2));print(len(r['graphics']),'pointers;',len(r['unresolvedAssociations']),'association reviews')+@@ -0,0 +1,63 @@+#!/usr/bin/env python3+"""KiCad mask obstacles, including complete custom-pad primitives, in board mm.+Does not substitute a custom pad's anchor for its copper/mask polygon.+Unsupported custom primitives fail closed. Caller supplies body/via obstacles.+"""+import json,re,math,argparse+from pathlib import Path+from shapely.geometry import Polygon,Point,LineString,box,mapping+from shapely.affinity import rotate,translate+from shapely.ops import unary_union++def parse(text):+ ts=iter(re.findall(r'"(?:\\.|[^"\\])*"|[^\s()]+|[()]',text))+ def rec():+ a=[]+ for t in ts:+ if t==')':return a+ a.append(rec() if t=='(' else json.loads(t) if t.startswith('"') else t)+ return a+ return rec()[0]+def sub(n,k):return [x for x in n if isinstance(x,list) and x and x[0]==k]+def one(n,k):return next(iter(sub(n,k)),[k])[1:]+def xy(n,k):return tuple(map(float,one(n,k)[:2]))+def primitive(n):+ kind=n[0];width=float((one(n,'width') or [0])[0]);fill=one(n,'fill')==['yes']+ if kind=='gr_poly':+ p=Polygon([tuple(map(float,v[1:3])) for v in one(n,'pts') if v[0]=='xy']);g=p if fill else p.exterior+ elif kind=='gr_rect':+ a,b=xy(n,'start'),xy(n,'end');p=box(min(a[0],b[0]),min(a[1],b[1]),max(a[0],b[0]),max(a[1],b[1]));g=p if fill else p.exterior+ elif kind=='gr_line':g=LineString([xy(n,'start'),xy(n,'end')])+ elif kind=='gr_circle':+ center=xy(n,'center');p=Point(center).buffer(math.dist(center,xy(n,'end')),quad_segs=24);g=p if fill else p.exterior+ else:raise ValueError('Unsupported custom-pad primitive: '+kind)+ return g.buffer(width/2) if width else g++def pad_shape(p):+ w,h=map(float,one(p,'size'));kind=p[3];rect=box(-w/2,-h/2,w/2,h/2)+ if kind=='custom':+ primitives=one(p,'primitives')+ if not primitives:raise ValueError('Custom pad without primitives')+ anchor=one(one(p,'options'),'anchor');base=Point(0,0).buffer(min(w,h)/2) if anchor==['circle'] else rect+ return unary_union([base]+[primitive(n) for n in primitives])+ if kind=='rect':return rect+ if kind=='circle':return Point(0,0).buffer(w/2,quad_segs=24)+ if kind=='oval':return LineString([(-(w-h)/2,0),((w-h)/2,0)]).buffer(h/2) if w>=h else LineString([(0,-(h-w)/2),(0,(h-w)/2)]).buffer(w/2)+ if kind=='roundrect':+ r=min(w,h)*float((one(p,'roundrect_rratio') or [0])[0]);return box(-w/2+r,-h/2+r,w/2-r,h/2-r).buffer(r) if r else rect+ raise ValueError('Unsupported mask pad shape: '+kind)+def obstacles(root,clearance=0.0):+ result=[];setup=one(root,'setup');globalmargin=float((one(setup,'pad_to_mask_clearance') or [0])[0])+ for f in sub(root,'footprint'):+ ref=next((x[2] for x in sub(f,'property') if x[1]=='Reference'),'?');at=list(map(float,one(f,'at')));fa=at[2] if len(at)>2 else 0;fm=float((one(f,'solder_mask_margin') or [globalmargin])[0])+ for idx,p in enumerate(sub(f,'pad')):+ layers=one(p,'layers');sides=[s for s in ['F','B'] if s+'.Mask' in layers or '*.Mask' in layers]+ if not sides:continue+ pa=list(map(float,one(p,'at')));angle=pa[2] if len(pa)>2 else fa;center=rotate(Point(pa[:2]),-fa,origin=(0,0));margin=float((one(p,'solder_mask_margin') or [fm])[0]);g=pad_shape(p).buffer(margin+clearance)+ if g.is_empty:continue+ g=translate(rotate(g,-angle,origin=(0,0)),at[0]+center.x,at[1]+center.y)+ for side in sides:result.append(dict(id=f'{ref}:pad:{p[1]}:{idx}',reference=ref,pad=p[1],kind='pad-mask-or-hole',layer=side+'.SilkS',polygon=mapping(g),box=list(g.bounds),sourceShape=p[3],maskMargin=margin,silkClearance=clearance))+ return result+if __name__=='__main__':+ p=argparse.ArgumentParser();p.add_argument('--board',required=True);p.add_argument('--out',required=True);a=p.parse_args();r=obstacles(parse(Path(a.board).read_text()));Path(a.out).write_text(json.dumps(r));print(len(r),'native mask obstacles; custom pads',sum(o['sourceShape']=='custom' for o in r))+@@ -0,0 +1,44 @@+#!/usr/bin/env python3+"""Independent polygon preflight for text, pointer strokes and enclosure strokes.+Inputs must include native mask polygons, fitted bodies, holes and existing artwork.+Fail closed for missing required inventory or invalid association geometry.+"""+import json,argparse+from pathlib import Path+from shapely.geometry import shape,box,LineString+from shapely.strtree import STRtree++def check(data):+ errors=[];placements=data['placements'];obstacles=data['obstacles'];required=set(data.get('requiredIndices',[]));present=[p['index'] for p in placements]+ if not required:errors.append(dict(kind='missing-coverage-contract'))+ for i in sorted(required-set(present)):errors.append(dict(kind='missing-label',index=i))+ if len(present)!=len(set(present)):errors.append(dict(kind='duplicate-label'))+ for side in ['F.SilkS','B.SilkS']:+ obs=[o for o in obstacles if o['layer']==side];geoms=[shape(o['polygon']) for o in obs];tree=STRtree(geoms)+ marks=[(p['index'],'text',box(*p['box'])) for p in placements if p['layer']==side]+ for g in data.get('graphics',[]):+ if g['layer']==side:marks.append((g['index'],g['kind'],LineString(g['samples']).buffer(g.get('width',.06)/2)))+ for index,kind,g in marks:+ if not g.is_valid:errors.append(dict(kind='invalid-mark',index=index));continue+ for k in tree.query(g):+ o=obs[int(k)];other=geoms[int(k)]+ if g.intersection(other).area>1e-7:errors.append(dict(kind='obstacle-collision',index=index,mark=kind,obstacle=o.get('id',o['reference']),obstacleKind=o['kind'],layer=side))+ # Text rectangles may not overlap. Association lines also reserve their stroke width.+ tree=STRtree([g for _,_,g in marks])+ for k,(index,kind,g) in enumerate(marks):+ for j in tree.query(g):+ j=int(j)+ if j>=k:continue+ otherindex,otherkind,other=marks[j]+ if kind!='text' and otherkind!='text':continue # joined segments are allowed+ if g.intersection(other).area>1e-7:errors.append(dict(kind='mark-collision',index=index,otherIndex=otherindex,mark=kind,otherMark=otherkind,layer=side))+ groups={}+ for p in placements:+ if p.get('reference') and p.get('role') in ['reference','value']:groups.setdefault((p['layer'],p['reference']),{})[p['role']]=p+ if p.get('lineSizes') and any(z>=p['lineSizes'][0] for z in p['lineSizes'][1:]):errors.append(dict(kind='multiline-hierarchy',index=p['index']))+ for (side,ref),g in groups.items():+ if 'value' in g and ('reference' not in g or g['value']['size']>g['reference']['size']*.8):errors.append(dict(kind='pair-hierarchy',reference=ref,layer=side))+ return dict(passed=not errors,errors=errors,labels=len(placements),graphics=len(data.get('graphics',[])),obstacles=len(obstacles),nativeVerified=False)+if __name__=='__main__':+ p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--out',required=True);a=p.parse_args();r=check(json.loads(Path(a.input).read_text()));Path(a.out).write_text(json.dumps(r,indent=2));print(('OK' if r['passed'] else 'ERROR')+': '+str(len(r['errors']))+' preflight findings');raise SystemExit(0 if r['passed'] else 2)+@@ -0,0 +1,66 @@+#!/usr/bin/env python3+"""Prefer board-edge side; optionally reserve a printed contact/name/value border.+Default contactBorders=true. The four border stroke rectangles participate in the+same collision solve as text. Full enclosure must contain no unrelated obstacles.+"""+import json,argparse,math+from pathlib import Path+from shapely.geometry import shape,Point,box,LineString+from shapely.ops import nearest_points,unary_union+from shapely.prepared import prep++def apply_policy(spec,geometry,enabled=True):+ outline=shape(geometry['outline']);stats={};bands={}+ # Shared rails and row heights are design constraints, not independent box fits.+ rows={}+ for g in spec['labels']:+ if g.get('orderGroup') and g['id'].startswith('MC') and g.get('candidates'):+ side=g['candidates'][0]['children'][0]['layer'];rows.setdefault((side,g['orderGroup']),[]).append(g)+ for (side,key),members in rows.items():+ if len(members)<3:continue+ xs=[g['anchor'][0] for g in members]+ if max(xs)-min(xs)>.1:continue+ ys=sorted(g['anchor'][1] for g in members);pitch=min(b-a for a,b in zip(ys,ys[1:]) if b-a>.1)+ targets=[shape(geometry['bodies'][side][g['id']]).bounds for g in members]+ if sum(xs)/len(xs)<outline.centroid.x:+ left=outline.bounds[0]+.12;right=max(b[2] for b in targets)+.10+ else:+ left=min(b[0] for b in targets)-.10;right=outline.bounds[2]-.12+ for g in members:bands[g['id']]=(left,right,pitch-.20,key)++ for group in spec['labels']:+ ref=group['id'];iscontact=ref.startswith(('MC','MP'))+ if not iscontact:continue+ anchor=Point(group['anchor']);edge=nearest_points(anchor,outline.exterior)[1];vx,vy=edge.x-anchor.x,edge.y-anchor.y+ side=group['candidates'][0]['children'][0]['layer'] if group['candidates'] else geometry.get('layer','F.SilkS')+ obs=[o for o in geometry['obstacles'] if o['layer']==side];target=shape(geometry['bodies'][side][ref]);fixed=prep(unary_union([shape(o['polygon']) for o in obs]));unrelated=prep(unary_union([shape(o['polygon']) for o in obs if o['reference']!=ref and o['kind'] not in ['native-silk-graphic','via-hole-and-mask']]))+ mask=unary_union([shape(o['polygon']).buffer(.04) for o in obs])+ kept=[];counts={'outside':0,'inside':0,'borderRejected':0}+ for c in group['candidates']:+ # Large primary penalty makes outside preferred ahead of font, within coverage.+ direction=(c['position'][0]-anchor.x)*vx+(c['position'][1]-anchor.y)*vy+ placementSide='outside' if direction>0 else 'inside';c['contactSide']=placementSide;c['qualityPenalty']=c.get('qualityPenalty',0)+(0 if placementSide=='outside' else 200)+ if enabled:+ bb=unary_union([target]+[box(*ch['box']) for ch in c['children']]).bounds+ region=box(bb[0]-.10,bb[1]-.10,bb[2]+.10,bb[3]+.10)+ if ref in bands and placementSide=='outside':+ left,right,height,key=bands[ref];shared=box(left,anchor.y-height/2,right,anchor.y+height/2)+ if not shared.buffer(-.06).covers(unary_union([target]+[box(*ch['box']) for ch in c['children']])):counts['borderRejected']+=1;continue+ region=shared;c['alignmentGroup']=key;c['sharedBorderBounds']=list(region.bounds)+ ring=region.exterior;stroke=ring.buffer(.03,join_style=2)+ if not outline.contains(stroke) or unrelated.intersects(region):counts['borderRejected']+=1;continue+ # Keep a visible grouping frame; make explicit safety gaps at nearby holes/artwork.+ safe=ring.difference(mask);segments=[safe] if safe.geom_type=='LineString' else list(safe.geoms) if safe.geom_type=='MultiLineString' else []+ segments=[line for line in segments if line.length>=.15]+ if not segments or sum(line.length for line in segments)<ring.length*.70:counts['borderRejected']+=1;continue+ points=list(ring.coords);paths=[list(line.coords) for line in segments]+ c['children'][0]['associationSegments']=paths;c['children'][0]['contactBorder']=True;c['contactBorder']=paths;c['borderStyle']='closed' if len(segments)==1 and segments[0].is_ring else 'interrupted-for-clearance'+ c['boxes'] += [list(LineString([a,b]).buffer(.03,cap_style=2).bounds) for line in paths for a,b in zip(line,line[1:])]+ c['box']=list(stroke.bounds)+ c['contactBorders']=enabled;kept.append(c);counts[placementSide]+=1+ group['candidates']=kept;stats[ref]=counts+ spec['contactBorders']=enabled;spec['contactPolicy']='nearest board edge first, coherent ordered rows, shared column border rails and row height, inside fallback';spec['contactPolicyStats']=stats+ return spec+if __name__=='__main__':+ p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--geometry',required=True);p.add_argument('--out',required=True);p.add_argument('--no-contact-borders',action='store_true');a=p.parse_args();r=apply_policy(json.loads(Path(a.input).read_text()),json.loads(Path(a.geometry).read_text()),not a.no_contact_borders);Path(a.out).write_text(json.dumps(r));print(json.dumps(r['contactPolicyStats']))+@@ -0,0 +1,71 @@+"""Place semantic documentation blocks; never scatter or reorder their rows.+Native measured bounds are supplied by the EDA adapter. World coordinates/mm.+"""+import numpy as np+import shapely+from shapely.geometry import box+from shapely.ops import unary_union++def place_sections(sections, labels, bounds, outline, obstacle, occupied, side):+ x0,y0,x1,y1=outline.bounds+ gx,gy=np.meshgrid(np.arange(x0,x1,.35),np.arange(y0,y1,.35))+ gx,gy=gx.ravel(),gy.ravel()+ accepted=[]; unresolved=[]; reports=[]+ for section in sections:+ chosen=None+ for font in section.get('fonts',[.6,.5,.4,.35,.3]):+ rows=section['rows']; ncols=max(map(len,rows)); colwidth=[]+ for col in range(ncols):+ colwidth.append(max([bounds(i,font*section.get('sizeFactors',{}).get(str(i),1),0)[2]-bounds(i,font*section.get('sizeFactors',{}).get(str(i),1),0)[0] for row in rows for j,i in enumerate(row) if j==col]+[0])+.8)+ raw=[]; y=0+ for row in rows:+ h=max(bounds(i,font*section.get('sizeFactors',{}).get(str(i),1),0)[3]-bounds(i,font*section.get('sizeFactors',{}).get(str(i),1),0)[1] for i in row)+ for col,i in enumerate(row):+ size=font*section.get('sizeFactors',{}).get(str(i),1); b=bounds(i,size,0)+ # Bottom is viewed from below: align right in board coordinates,+ # which becomes a shared left edge in the readable bottom view.+ dx=sum(colwidth[:col])-(b[2] if side=='B.SilkS' else b[0]);dy=y-b[1]+ raw.append((i,size,dx,dy,[b[0]+dx,b[1]+dy,b[2]+dx,b[3]+dy]))+ y+=h+max(.28,font*.65)+ envelope=unary_union([box(*r[4]) for r in raw]).bounds+ valid=shapely.contains(outline,shapely.box(gx+envelope[0],gy+envelope[1],gx+envelope[2],gy+envelope[3]))+ # Reserve the whole section against other labels, but permit holes+ # between rows when the actual strokes have clearance.+ valid &= ~shapely.intersects(occupied,shapely.box(gx+envelope[0]-.15,gy+envelope[1]-.15,gx+envelope[2]+.15,gy+envelope[3]+.15))+ for _,_,_,_,b in raw:+ indices=np.flatnonzero(valid)+ if not len(indices):break+ geoms=shapely.box(gx[indices]+b[0]-.045,gy[indices]+b[1]-.045,gx[indices]+b[2]+.045,gy[indices]+b[3]+.045)+ valid[indices] &= ~shapely.intersects(obstacle,geoms)+ indices=np.flatnonzero(valid)+ if len(indices):+ ax,ay=section['anchor']; n=indices[np.argmin((gx[indices]+(envelope[0]+envelope[2])/2-ax)**2+(gy[indices]+(envelope[1]+envelope[3])/2-ay)**2)]+ chosen=[]+ for i,size,dx,dy,b in raw:+ l=labels[i];chosen.append(dict(index=i,text=l['text'],reference=None,layer=side,role=l['role'],section=section['id'],textAlign='left',x=float(gx[n]+dx),y=float(gy[n]+dy),size=size,angle=0,box=[float(b[0]+gx[n]),float(b[1]+gy[n]),float(b[2]+gx[n]),float(b[3]+gy[n])]))+ occupied=unary_union([occupied,box(envelope[0]+gx[n]-.15,envelope[1]+gy[n]-.15,envelope[2]+gx[n]+.15,envelope[3]+gy[n]+.15)])+ reports.append(dict(id=section['id'],font=font,indices=[p['index'] for p in chosen],status='placed'));break+ if chosen:accepted+=chosen+ else:+ ids=[i for row in section['rows'] for i in row];unresolved+=ids;reports.append(dict(id=section['id'],status='unresolved',indices=ids))+ return accepted,unresolved,occupied,reports++if __name__=='__main__':+ import argparse,json+ from pathlib import Path+ from shapely.geometry import shape,GeometryCollection+ p=argparse.ArgumentParser();p.add_argument('--input',required=True);p.add_argument('--out',required=True);a=p.parse_args()+ d=json.loads(Path(a.input).read_text());metrics=d['metrics']+ def bounds(i,size,angle):+ if angle:raise ValueError('Documentation sections require readable horizontal rows')+ return [v*size for v in metrics[str(i)]]+ out=[];missing=[];reports=[]+ for side in ['F.SilkS','B.SilkS']:+ obstacle=unary_union([shape(o['polygon']) for o in d['obstacles'] if o['layer']==side])+ occupied=unary_union([box(*p['box']).buffer(.035) for p in d.get('placements',[]) if p['layer']==side])+ ps,ms,_,rs=place_sections([s for s in d['sections'] if s['layer']==side],d['labels'],bounds,shape(d['outline']),obstacle,occupied,side)+ out+=ps;missing+=ms;reports+=rs+ Path(a.out).write_text(json.dumps(dict(placements=out,unresolved=missing,sections=reports),indent=2))+ print(('ERROR' if missing else 'OK')+f': {len(out)} documentation labels in ordered sections; {len(missing)} unresolved')+ raise SystemExit(2 if missing else 0)+@@ -0,0 +1,27 @@+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')+# Missing values and equal-size values are invalid even if their score is higher.+g={'id':'C1','anchor':[0,0],'requiredRoles':['reference','value'],'maxValueRatio':.8,'candidates':[]}+for roles,font in [([{'role':'reference','size':.8}],.9),([{'role':'reference','size':.4},{'role':'value','size':.4}],.8),([{'role':'reference','size':.4},{'role':'value','size':.25}],.4)]:+ x=c(0,font);x['children']=roles;g['candidates'].append(x)+r=m.solve({'bounds':[-1,-1,6,3],'labels':[g]});assert not r['unresolved'];assert r['placements'][0]['font']==.4+print('PASS: required pair roles and reference/value hierarchy cannot be traded for score')+@@ -0,0 +1,19 @@+import importlib.util+from pathlib import Path+from shapely.geometry import shape,box+sp=importlib.util.spec_from_file_location('mask',Path(__file__).with_name('silkscreen-kicad-mask.py'));m=importlib.util.module_from_spec(sp);sp.loader.exec_module(m)+# ESC regression: rotated MOSFET drain mask extends far beyond the 1 mm anchor.+s='''(kicad_pcb (setup (pad_to_mask_clearance 0.09))+(footprint X (at 154 107 180) (property "Reference" "Q1")+(pad "5" smd custom (at 1.05 0 180) (size 1 1) (layers "F.Cu" "F.Mask")+(options (anchor rect)) (primitives (gr_poly (pts (xy -2.375 -2.305) (xy 2.375 -2.305) (xy 2.375 2.305) (xy -2.375 2.305)) (width 0) (fill yes))))))'''+o=m.obstacles(m.parse(s));assert len(o)==1;g=shape(o[0]['polygon']);assert g.intersects(box(149.928,106.387,150.788,107.613));assert abs(g.bounds[0]-150.485)<1e-6+# Global mask margin is applied exactly once; footprint rotation transforms position.+s='''(kicad_pcb (setup (pad_to_mask_clearance 0.09)) (footprint X (at 127.7 85.2 90) (property "Reference" "C6") (pad "1" smd roundrect (at -0.48 0 90) (size 0.56 0.62) (roundrect_rratio 0.25) (layers "F.Cu" "F.Mask"))))'''+g=shape(m.obstacles(m.parse(s))[0]['polygon']);assert all(abs(a-b)<1e-6 for a,b in zip(g.bounds,[127.3,85.31,128.1,86.05]))+# An unknown primitive must not silently fall back to the anchor rectangle.+try:m.primitive(['gr_unknown'])+except ValueError:pass+else:raise AssertionError('unsupported geometry accepted')+print('PASS: complete custom mask, rotated coordinates, margin once, unsupported geometry refused')+@@ -0,0 +1,20 @@+import importlib.util,copy+from pathlib import Path+from shapely.geometry import box,Point,mapping+sp=importlib.util.spec_from_file_location('policy',Path(__file__).with_name('silkscreen-contact-policy.py'));m=importlib.util.module_from_spec(sp);sp.loader.exec_module(m)+g=dict(outline=mapping(box(0,0,10,10)),bodies={'F.SilkS':{'MC1':mapping(Point(2,5).buffer(.4))}},obstacles=[dict(reference='MC1',kind='pad-mask-or-hole',layer='F.SilkS',polygon=mapping(Point(2,5).buffer(.4)))])+def cand(x,font):+ bb=[x-.2,4.8,x+.2,5.2];return dict(font=font,position=[x,5],box=bb,boxes=[bb],children=[dict(index=0,reference='MC1',layer='F.SilkS',box=bb)])+s=dict(labels=[dict(id='MC1',anchor=[2,5],candidates=[cand(.8,.4),cand(3.5,.8)])]);on=m.apply_policy(copy.deepcopy(s),g);assert len(on['labels'][0]['candidates'])==2+outer,inner=on['labels'][0]['candidates'];assert outer['contactSide']=='outside';assert inner['contactSide']=='inside';assert outer['font']*20-outer['qualityPenalty']>inner['font']*20-inner['qualityPenalty'];assert outer['children'][0]['contactBorder'];assert len(outer['boxes'])>1+off=m.apply_policy(copy.deepcopy(s),g,False);assert all(not c['children'][0].get('contactBorder') for c in off['labels'][0]['candidates']);assert off['labels'][0]['candidates'][0]['contactSide']=='outside'+print('PASS: outside preference outranks font; border strokes reserved; optional off preserves side policy')+labels=[];bodies={};obstacles=[]+for k,y in enumerate([3,5,7]):+ ref=f'MC{k+1}';body=mapping(Point(2,y).buffer(.4));bodies[ref]=body;obstacles.append(dict(reference=ref,kind='pad-mask-or-hole',layer='F.SilkS',polygon=body));bb=[.5,y-.25,1.0+k*.2,y+.25]+ labels.append(dict(id=ref,anchor=[2,y],orderGroup='left-column',candidates=[dict(font=.5,position=[.8,y],box=bb,boxes=[bb],children=[dict(index=k,reference=ref,layer='F.SilkS',box=bb)])]))+a=m.apply_policy(dict(labels=labels),dict(outline=mapping(box(0,0,10,10)),bodies={'F.SilkS':bodies},obstacles=obstacles))+bs=[l['candidates'][0]['sharedBorderBounds'] for l in a['labels']]+assert len({(round(b[0],5),round(b[2],5),round(b[3]-b[1],5)) for b in bs})==1+print('PASS: unequal text widths retain shared border rails and row height')+@@ -0,0 +1,19 @@+import importlib.util,unittest+from pathlib import Path+from shapely.geometry import box,GeometryCollection+s=importlib.util.spec_from_file_location('sections',Path(__file__).with_name('silkscreen-sections.py'));m=importlib.util.module_from_spec(s);s.loader.exec_module(m)+class Sections(unittest.TestCase):+ def test_rows_stay_ordered_and_bottom_reading_edge_aligns(self):+ labels=[dict(text=t,role='service-key') for t in ['Heading','a longer row','short']]+ bounds=lambda i,z,a:[-len(labels[i]['text'])*z/3,-z/2,len(labels[i]['text'])*z/3,z/2]+ section=dict(id='test',rows=[[0],[1],[2]],anchor=[10,10],sizeFactors={'0':1.5})+ for side in ['F.SilkS','B.SilkS']:+ p,missing,_,r=m.place_sections([section],labels,bounds,box(0,0,20,20),box(8,8,9,9),GeometryCollection(),side)+ self.assertEqual(missing,[]);self.assertEqual([x['index'] for x in p],[0,1,2]);self.assertTrue(p[0]['y']<p[1]['y']<p[2]['y']);self.assertGreater(p[0]['size'],p[1]['size'])+ edge=2 if side=='B.SilkS' else 0+ self.assertAlmostEqual(p[0]['box'][edge],p[1]['box'][edge]);self.assertAlmostEqual(p[1]['box'][edge],p[2]['box'][edge])+ def test_impossible_section_is_not_scattered(self):+ p,missing,_,_=m.place_sections([dict(id='blocked',rows=[[0],[1]],anchor=[1,1])],[dict(text='a',role='note')]*2,lambda i,z,a:[-z,-z,z,z],box(0,0,2,2),box(0,0,2,2),GeometryCollection(),'F.SilkS')+ self.assertEqual(p,[]);self.assertEqual(missing,[0,1])+if __name__=='__main__':unittest.main()+@@ -0,0 +1,57 @@+<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Adom AI Flow · Silkscreen</title><link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23e6edf3%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%223%22%20y%3D%223%22%20width%3D%2218%22%20height%3D%2218%22%20rx%3D%222%22/%3E%3Cpath%20d%3D%22M7%2016V8h4M7%2012h3M14%208h3M15.5%208v8M14%2016h3%22/%3E%3C/svg%3E"><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}}++@font-face{font-family:Satoshi;src:url(data:font/woff2;base64,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)}+@font-face{font-family:Familjen;src:url(data:font/woff2;base64,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)}+:root{--bg:#0d1117;--surface:#161b22;--border:#30363d;--accent:#00b8b1;font-family:Satoshi,sans-serif;color:#c9d1d9;background:var(--bg)}+body{grid-template-rows:44px 44px 1fr 66px}header{height:44px;padding:0 16px;gap:16px;background:var(--surface);border-color:var(--border)}+.brand{display:flex;align-items:center;gap:8px;font:600 16px Familjen,sans-serif;color:var(--accent);position:relative;white-space:nowrap}.brand img{width:16px;height:16px}.subject{border-left:1px solid var(--border);padding-left:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e6edf3}.actions{margin-left:auto;display:flex;gap:8px}.actions button{width:32px;height:32px;padding:6px}.actions svg{width:18px;height:18px;fill:none;stroke:#e6edf3;stroke-width:1.8}.toolbar{display:flex;gap:12px;align-items:center;padding:4px 16px;background:var(--surface);border-bottom:1px solid var(--border)}button,select,input{background:#21262d;border-color:var(--border);border-radius:8px}button:hover{border-color:var(--accent)}button.active{background:#163e3d}aside{background:var(--surface)}h2{font-family:Familjen,sans-serif}.muted,p{color:#8b949e}#settings{top:50%;left:50%;right:auto;transform:translate(-50%,-50%);min-width:340px;z-index:20;box-shadow:0 0 0 100vmax #0004;background:#21262d;border-color:var(--border)}#appinfo{position:absolute;top:30px;left:0;width:320px;padding:16px;background:#21262d;border:1px solid var(--border);border-radius:8px;z-index:25;color:#c9d1d9;white-space:normal;font:14px Satoshi,sans-serif;pointer-events:auto}#textsize{font-variant-numeric:tabular-nums;color:#e6edf3;padding:10px 0}#fit{margin-left:0}@media(max-width:720px){.subject{display:none}.toolbar{gap:5px}main{grid-template-columns:1fr 210px}}++#gear{display:grid;place-items:center;flex:0 0 32px;padding:6px}#gear svg{display:block;flex:none;width:20px;height:20px;aspect-ratio:1;stroke-width:1.8}+#settings{position:fixed;inset:0;margin:auto;transform:none;width:min(560px,calc(100vw - 32px));min-width:0;max-height:calc(100vh - 32px);padding:0;overflow:hidden;border:1px solid #30363d;border-radius:12px;background:#161b22;color:#e6edf3;box-shadow:0 16px 60px #0006}+#settings::backdrop{background:rgba(0,0,0,.25);backdrop-filter:none}.settings-heading{display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid #30363d;cursor:move}.settings-heading h2{font-size:18px;margin:0}.settings-heading button{display:grid;place-items:center;width:32px;height:32px;padding:5px}.settings-body{padding:18px;max-height:65vh;overflow:auto;scrollbar-width:thin;scrollbar-color:#6e768166 transparent}.settings-card{background:#1c2128;border:1px solid #30363d;border-left:3px solid #00b8b173;border-radius:10px;padding:14px 17px;margin-bottom:16px}.settings-card:last-child{margin-bottom:0}.card-title{font-size:13px;font-weight:600;display:flex;align-items:center;gap:10px}.card-status{font-size:11px;color:#8b949e;font-weight:400}.card-desc{font-size:12px;line-height:1.55;color:#8b949e}.setting-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-top:1px solid #30363d}#settings .setting-row label{margin:0;font-size:13px;line-height:1.5}.setting-row label span{color:#00b8b1}.setting-row small{display:block;color:#8b949e;font-size:11px}.setting-row input{width:86px;font-size:13px}.settings-footer{border-top:1px solid #30363d;padding:14px 18px;display:flex;align-items:center;justify-content:space-between;gap:16px;font-size:11px;color:#8b949e}.settings-footer button{font-size:12px}+.toggle{background:none;border:none;padding:2px;cursor:pointer;flex-shrink:0;transition:transform .06s ease}.toggle-track{display:block;width:32px;height:18px;background:#21262d;border:1px solid #30363d;border-radius:9px;position:relative;transition:background .15s,border-color .15s}.toggle:hover .toggle-track{border-color:#00b8b1}.toggle:active{transform:translateY(1px) scale(.97)}.toggle[aria-checked="true"] .toggle-track{background:#00b8b1;border-color:#00b8b1}.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s,width .12s}.toggle[aria-checked="true"] .toggle-thumb{transform:translateX(14px)}.toggle:active .toggle-thumb{width:16px}.toggle[aria-checked="true"]:active .toggle-thumb{transform:translateX(10px)}button:focus-visible,input:focus-visible{outline:2px solid #00b8b1;outline-offset:2px}button:active{transform:translateY(1px)}#settings-toast{position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:#21262d;border:1px solid #30363d;border-left:3px solid #00b8b1;padding:10px 16px;border-radius:8px;z-index:100}+</style></head><body><header><div class="brand" tabindex="0"><img src="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23e6edf3%22%20stroke-width%3D%221.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%223%22%20y%3D%223%22%20width%3D%2218%22%20height%3D%2218%22%20rx%3D%222%22/%3E%3Cpath%20d%3D%22M7%2016V8h4M7%2012h3M14%208h3M15.5%208v8M14%2016h3%22/%3E%3C/svg%3E" alt=""><span>Adom AI Flow · Silkscreen</span><div id="appinfo" hidden>Silkscreen dashboard · development build<br>Prototype: Rust CLI, Python helpers, browser UI<br>Authored by Codex Astra<br><a href="https://wiki.adom.inc/adom/adom-aiflow">AI Flow wiki and source</a></div></div><div class="subject"><span id="subject">Board</span> <span class="muted"> · full-board label placement</span></div><div class="actions"><button id="gear" aria-label="Settings" title="Settings"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"+ fill="none" stroke="#e6edf3" stroke-width="1.8"+ stroke-linecap="round" stroke-linejoin="round">+ <!-- THE Adom settings cog: a true line drawing (strokes, no filled silhouette).+ Hardcoded #e6edf3 because this file is loaded as its own document via+ <img src> / favicon, where currentColor would resolve to black. Inline+ copies pasted into markup may swap the stroke to currentColor. -->+ <circle cx="12" cy="12" r="3"/>+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>+</svg>+</button></div></header><div class="toolbar"><span id="faceCue" class="badge">Top · F.SilkS</span><span id="mode" class="badge">Connecting</span><select id="face" aria-label="Board face"><option value="F.SilkS">Top only</option><option value="B.SilkS">Bottom only</option></select><button id="two" class="active">2D layout</button><button id="threeBtn">3D inspection</button><button id="fit">Fit board</button></div><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><div id="textsize">Text height in mm appears with each placement</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="border:1px dashed #ffd479;opacity:.5"></i>Dashed 50%: calculation guides only</div><div>Solid text, pointers and outlines: printed silkscreen</div><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:linear-gradient(90deg,#ffd479 50%,#79b8ff 50%)"></i>Placed: top gold / bottom blue</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><dialog id="settings" tabindex="-1" aria-labelledby="settings-title"><div class="settings-heading"><h2 id="settings-title">Silkscreen Settings</h2><button id="close-settings" aria-label="Close settings" title="Close settings"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m6 6 12 12M18 6 6 18"/></svg></button></div><div class="settings-body"><section class="settings-card"><div class="card-title">Playback and display <span class="card-status">Applies immediately</span></div><p class="card-desc">Replay the recorded placement decisions at your own pace. Calculation guides are dashed and translucent; solid marks represent the silkscreen sent to the EDA tool. These controls do not change the board.</p><div class="setting-row"><label for="speed">Replay / <span>Speed</span><small>Default: 4 events per second</small></label><input id="speed" type="number" min="0.5" max="60" step="0.5" title="Changes playback speed, preserving every recorded event."></div><div class="setting-row"><label id="guides-label">Display / <span>Calculation guides</span><small>Default: On</small></label><button id="obstacles" class="toggle" role="switch" aria-labelledby="guides-label" aria-checked="true" title="Show dashed text bounds, model envelopes and hole obstacles. Printed silkscreen stays visible."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div><div class="setting-row"><label id="follow-label">Camera / <span>Follow placement</span><small>Default: On · this view only</small></label><button id="follow" class="toggle" role="switch" aria-labelledby="follow-label" aria-checked="true" title="Frame each placement during replay. Manual zoom or pan turns this off."><span class="toggle-track"><span class="toggle-thumb"></span></span></button></div></section><section class="settings-card"><div class="card-title">Local server <span class="card-status">No provider calls</span></div><p class="card-desc">The dashboard reads saved geometry and events from this run. Its idle timer stops the dashboard server after inactivity; saved board files and recordings remain on disk.</p><div class="setting-row"><label for="ttl">Server / <span>Idle shutdown</span><small>Default: 24 hours</small></label><input id="ttl" type="number" min="1" max="168" title="Hours without dashboard activity before the local server shuts down."></div></section></div><div class="settings-footer"><span>Settings save on this machine.<br>Development build · native review required.</span><button id="restore-settings">Restore defaults</button></div></dialog><div id="settings-toast" role="status" hidden></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'},signal:path==='settings'?AbortSignal.timeout(5000):undefined,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};for(const id of ['obstacles','follow'])Object.defineProperty($('#'+id),'checked',{get(){return this.getAttribute('aria-checked')==='true'},set(v){this.setAttribute('aria-checked',String(!!v))}});+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);const bottomFace=state.layer==='B.SilkS';if(bottomFace&&!texture){c.translate(extent[0]+extent[2],0);c.scale(-1,1)}c.lineWidth=1/scale;+function rect(b,color,fill=true){c.save();c.fillStyle=color;c.strokeStyle=color;if(fill)c.fillRect(b[0],b[1],b[2]-b[0],b[3]-b[1]);else{c.globalAlpha=.5;c.setLineDash([5/scale,4/scale]);c.lineWidth=1/scale;c.strokeRect(b[0],b[1],b[2]-b[0],b[3]-b[1])}c.restore()}+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.save();c.globalAlpha=.5;c.setLineDash([5/scale,4/scale]);c.strokeStyle=col;c.stroke();c.restore()}else{c.save();c.globalAlpha=.5;rect(o.box,col,true);c.restore();rect(o.box,col,false)}}}+const faceColor=state.layer==='B.SilkS'?'#79b8ff':'#ffd479';const placed=proposals();const replaced=new Set([...placed.values()].flatMap(p=>(p.children||[]).map(x=>x.index)));+function label(l,color,b){if(l.associationSegments){c.save();c.strokeStyle=color;c.lineWidth=.06;for(const line of l.associationSegments){c.beginPath();line.forEach((p,i)=>i?c.lineTo(...p):c.moveTo(...p));c.stroke()}c.restore()}if(l.associationOutline){c.beginPath();l.associationOutline.forEach((p,i)=>i?c.lineTo(...p):c.moveTo(...p));c.strokeStyle=color;c.lineWidth=.06;c.stroke()}if(l.leaderCurve){const p=l.leaderCurve;c.beginPath();c.moveTo(...p[0]);c.bezierCurveTo(...p[1],...p[2],...p[3]);c.strokeStyle=color;c.lineWidth=.08;c.stroke()}const aligned=Boolean(l.section||l.textAlign==='left'||l.alignmentTarget)&&l.box&&!(l.angle||0),edgeIndex=l.alignmentTarget?(l.alignmentTarget[0]>(l.box[0]+l.box[2])/2?2:0):(bottomFace?2:0),inkAlign=((edgeIndex===2)!==bottomFace)?'right':'left';c.save();c.translate(aligned?l.box[edgeIndex]:(l.x??l.position?.[0]??0),l.y??l.position?.[1]??0);c.rotate(-(l.angle||0)*Math.PI/180);if(bottomFace)c.scale(-1,1);c.font=`${l.size||l.font||.5}px sans-serif`;c.textAlign=aligned?inkAlign:'center';c.textBaseline='middle';c.fillStyle=color;(l.text||'').split('\n').forEach((s,i,all)=>{c.font=`${l.lineSizes?.[i]||l.size||l.font||.5}px sans-serif`;const yy=(i-(all.length-1)/2)*(l.size||l.font||.5)*1.5;if(aligned)c.fillText(s,0,yy,l.box[2]-l.box[0]);else c.fillText(s,0,yy)});c.restore();if(!texture&&b)rect(b,color,false)}+for(let i=0;i<data.labels.length;i++){const l=data.labels[i];if(data.freshPlacement||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,faceColor,!texture&&state.settings?.showObstacles?child.box:null)}}+const e=history[state.cursor];if(e?.candidate){const cs=e.candidate.children||[e.candidate];$('#textsize').textContent=cs.map(x=>(x.text||e.id)+': '+Number(x.size||x.font||0).toFixed(2)+' mm').join(' · ')}if(!texture&&e?.kind==='candidate'&&(!e.layer||e.layer===state.layer)&&(!e.candidate.layer||e.candidate.layer===state.layer)){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();$('#subject').textContent=data.name||data.sourceBoard||'Board';if(data.nativeReview)$('#native').textContent=data.nativeReview;$('#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}const changedFace=s.layer&&s.layer!==state.layer;state={...state,...s};if(changedFace){selected='';view=[...bounds]}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??0} 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;const bottom=state.layer==='B.SilkS';$('#faceCue').textContent=bottom?'Bottom · B.SilkS · viewed from below':'Top · F.SilkS · viewed from above';$('#faceCue').style.color=bottom?'#79b8ff':'#ffd479';$('#faceCue').style.borderColor=bottom?'#79b8ff':'#ffd479';$('#stage').style.boxShadow='inset 0 4px '+(bottom?'#64abff':'#e07c75');$('#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().values()].filter(p=>!p.layer||p.layer===state.layer).length} labels placed on this face`;$('#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;const focus=e?.candidate?.children?.[0]?.reference||e?.id;if(focus&&selected!==focus&&$('#follow').checked){selected=focus;const child=e?.candidate?.children?.[0];if(child?.box&&!child.reference){const b=child.box;frame([b[0]-3,b[1]-3,b[2]+3,b[3]+3])}else 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)}$('#subject').textContent=data.name||data.sourceBoard||'Board';if(data.nativeReview)$('#native').textContent=data.nativeReview;$('#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)};let toastTimer,restoreTimer,restoreArmed=false;function toast(message){const t=$('#settings-toast');t.textContent=message;t.hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.hidden=true,3000)}async function saveSettings(patch){try{await api('settings',patch);toast('Settings saved')}catch(e){toast('Could not save: '+e.message)}}const dlg=$('#settings');$('#gear').onclick=()=>{dlg.showModal();dlg.focus();$('.settings-body').scrollTop=0;requestAnimationFrame(()=>$('.settings-body').scrollTop=0)};$('#close-settings').onclick=()=>dlg.close();dlg.addEventListener('click',e=>{if(e.target!==dlg)return;const r=dlg.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)dlg.close()});$('#speed').onchange=e=>saveSettings({speed:+e.target.value});$('#ttl').onchange=e=>saveSettings({ttlHours:+e.target.value});$('#obstacles').onclick=e=>{const b=$('#obstacles');b.checked=!b.checked;saveSettings({showObstacles:b.checked})};$('#follow').onclick=()=>{const b=$('#follow');b.checked=!b.checked;toast(b.checked?'Camera follows placement':'Manual camera control')};$('#restore-settings').onclick=()=>{if(!restoreArmed){restoreArmed=true;$('#restore-settings').textContent='Confirm restore';restoreTimer=setTimeout(()=>{restoreArmed=false;$('#restore-settings').textContent='Restore defaults'},5000);return}clearTimeout(restoreTimer);restoreArmed=false;$('#restore-settings').textContent='Restore defaults';$('#follow').checked=true;saveSettings({speed:4,ttlHours:24,showObstacles:true})};let modalDrag;$('.settings-heading').onpointerdown=e=>{if(e.target.closest('button'))return;const r=dlg.getBoundingClientRect();modalDrag={x:e.clientX,y:e.clientY,left:r.left,top:r.top};e.currentTarget.setPointerCapture(e.pointerId)};$('.settings-heading').onpointermove=e=>{if(!modalDrag)return;dlg.style.margin='0';dlg.style.left=Math.max(0,Math.min(innerWidth-dlg.offsetWidth,modalDrag.left+e.clientX-modalDrag.x))+'px';dlg.style.top=Math.max(0,Math.min(innerHeight-dlg.offsetHeight,modalDrag.top+e.clientY-modalDrag.y))+'px';dlg.style.right='auto';dlg.style.bottom='auto'};$('.settings-heading').onpointerup=$('.settings-heading').onpointercancel=()=>modalDrag=null;+function boardPoint(clientX,clientY,v=view){const r=canvas.getBoundingClientRect(),w=v[2]-v[0],h=v[3]-v[1],scale=Math.min(r.width/w,r.height/h)*.93;const dx=(clientX-r.left-r.width/2)/scale,dy=(clientY-r.top-r.height/2)/scale;return [(v[0]+v[2])/2+(state.layer==='B.SilkS'?-dx:dx),(v[1]+v[3])/2+dy]}+window.silkscreen.camera={pointAt:(x,y)=>boardPoint(x,y),get extent(){return [...view]}};+function manualCamera(){cancelAnimationFrame(cameraAnimation);$('#follow').checked=false}+canvas.addEventListener('wheel',e=>{e.preventDefault();if(!view||!e.deltaY)return;manualCamera();const r=canvas.getBoundingClientRect(),delta=e.deltaY*(e.deltaMode===1?16:e.deltaMode===2?r.height:1),requested=Math.exp(Math.max(-1,Math.min(1,delta*.0015))),span=Math.max(view[2]-view[0],view[3]-view[1]),f=Math.max(.5,Math.min(1000,span*requested))/span,p=boardPoint(e.clientX,e.clientY);view=view.map((v,i)=>p[i%2]+(v-p[i%2])*f);draw()},{passive:false});+canvas.onpointerdown=e=>{if(!view)return;manualCamera();drag={x:e.clientX,y:e.clientY,v:[...view]};canvas.setPointerCapture(e.pointerId)};canvas.onpointermove=e=>{if(!drag)return;const a=boardPoint(drag.x,drag.y,drag.v),b=boardPoint(e.clientX,e.clientY,drag.v),dx=b[0]-a[0],dy=b[1]-a[1];view=[drag.v[0]-dx,drag.v[1]-dy,drag.v[2]-dx,drag.v[3]-dy];draw()};canvas.onpointerup=canvas.onpointercancel=()=>drag=null;++const captureCanvas=document.createElement('canvas');captureCanvas.width=1920;captureCanvas.height=1080;const captureCtx=captureCanvas.getContext('2d');function paintCapture(){paint(captureCtx,1920,1080,view);captureCtx.fillStyle='#0d1117e8';captureCtx.fillRect(0,0,1920,50);captureCtx.fillStyle='#e6edf3';captureCtx.font='22px sans-serif';captureCtx.fillStyle=state.layer==='B.SilkS'?'#79b8ff':'#ffd479';captureCtx.fillText('Adom AI Flow · '+(state.layer==='F.SilkS'?'Top, viewed from above':'Bottom, viewed from below')+' silkscreen · '+$('#textsize').textContent,20,32)}+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(captureCanvas.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()};paintCapture();rec.start();$('#record').textContent='Recording replay…';await api('control',{playing:true});const timer=setInterval(()=>{draw();paintCapture();if(!state.playing&&state.cursor>=history.length-1){clearInterval(timer);frame(bounds);const hold=setInterval(paintCapture,33);setTimeout(()=>{clearInterval(hold);paintCapture();rec.stop()},1200)}},100)};$('#record').onclick=()=>recordReplay(false);$('#short').onclick=()=>recordReplay(true);+let infoTimer;const brand=$('.brand'),info=$('#appinfo');brand.onmouseenter=()=>{clearTimeout(infoTimer);infoTimer=setTimeout(()=>info.hidden=false,600)};brand.onmouseleave=()=>{clearTimeout(infoTimer);infoTimer=setTimeout(()=>info.hidden=true,220)};brand.onfocus=()=>info.hidden=false;document.addEventListener('keydown',e=>{if(e.key==='Escape'){if($('#settings').open)$('#settings').close();info.hidden=true}});+new ResizeObserver(draw).observe($('#stage'));draw();}catch(e){$('#error').textContent=e.stack}+</script></body></html>+@@ -0,0 +1,79 @@+# 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.++## Full-board silkscreen review++A full placement demonstration starts with no authored labels in the candidate occupancy, then attempts every required label. Reuse text content, native glyph metrics and electrical anchors, not earlier accepted positions. Preserve unresolved attempts and subsequent rework in the event history. Front and back are separate collision/placement passes in one run. Filter candidates, selected labels, leaders and playback events by the active face; never overlay the opposite face during a recording.++Show the active label's native text height in millimetres, including distinct reference/value sizes. Maximize readable size near its physical feature; 0.20 mm is a last-resort InstaPCB value, not a default. Prefer short curved leader lines whenever contact/pin association is unclear. Route leaders to the feature's visible rim, keep them off holes, pads, bodies and other text, and verify both faces in the native EDA. Include leaders as actual placement/rework events.++Record each face as a 1920x1080 detailed replay with an explicit replay label. End the review video with the same revision in the user's native EDA, including top and bottom inspection. A dashboard preview or API success is not proof of a faithful native transfer. Keep native raw clips separate from captioned/replayed dashboard clips.++### Face orientation and label hierarchy+- Show bottom placement from below: reflect board geometry, pad positions, obstacles, leaders and label anchors together; keep lettering readable. Do not display unmirrored bottom text on unchanged top-view geometry. State the viewing direction alongside the face name in the dashboard and video.+- Use distinct face colors plus explicit F.SilkS/B.SilkS text; color alone is insufficient.+- Place each component reference and value as a group: the reference has priority and is larger; the value is mandatory and visibly smaller. A count of placed text objects does not prove this hierarchy or readability.+- Treat machine contacts, machine pins and connector pin labels as structured groups. Preserve ordered pin mapping, nearby placement and clear curved leaders when needed; compare against the previous native layout before accepting a fresh solve.++- 2D wheel/trackpad zoom must preserve the board point beneath the pointer, including aspect-fit margins and bottom-face reflection. Manual zoom or pan cancels camera animation and disables automatic follow until the user enables it again.++- Rendering contract: only manufacturing silkscreen is solid and fully opaque. Calculation-only text bounds, keepouts and obstacle outlines use dashed strokes at approximately 50% opacity, with an explicit legend. Never make a debug bounding box look like a printed enclosure. Captured footage must preserve the distinction.++### Machine-contact grouping (default on)+Prefer the label on the board-edge side of each machine contact, machine pin or edge+connector. Determine that direction in physical board coordinates from the nearest+outline edge, independent of whether the viewer is showing top or bottom. Keep a+column on the same side where feasible, preserve physical pin order, and use an+inside fallback only when the outside cannot fit collision-free. Explain fallbacks.++By default reserve a printed frame around each contact plus its larger reference and+smaller function/value. `contactBorders: false` / `--no-contact-borders` disables+these frames without disabling the outside-first policy. Borders are placement+geometry, not decoration added after the solve. Check their full stroke against+holes, mask openings, existing artwork, text and the board edge. A frame may have+small, explicitly reported clearance gaps; never draw across a hole to close it.+Display printed frames as solid; calculation bounding boxes remain dashed at 50%.++Before solving, perform a board-specific interface importance pass. For ESCs, give+verified PHASE A/B/C motor terminals conspicuously larger connection labels (target+1.0–1.4 mm when space allows) and prioritize battery input, polarity and ground.+A functional terminal name can outrank its MC reference; component values remain+smaller than their component references. Record the chosen semantic priorities in+the input and event explanation, then reserve those groups before secondary notes.+++### Documentation sections are atomic+Board identity, instructions, warnings and service-reference tables must retain semantic sections. Preserve row order, a shared readable alignment, consistent line spacing and a larger heading. Place or resize the whole block; never sort individual lines by length or scatter them on generic center-column anchors. Bottom blocks align in the actual bottom-face reading orientation. Keep reference tables supplementary to labels beside physical connectors. Report an unresolved section rather than silently disassembling it. Replay one section at a time and expose its text heights.++Documentation blocks default to left-aligned visible glyphs, not merely aligned calculation boxes. Use the readable left edge on both faces, including bottom reflection. The dashboard must not recenter a shorter font rendering inside the EDA-measured box. Native exports may use equivalent centered anchors computed from measured glyph bounds, provided the actual ink shares the left edge.++### Visual consistency across connector groups+Treat repeated connector labels as a designed row or column. Prefer shared outer border rails, common widths/heights, padding, reference/value hierarchy and consistent spacing. Solve these at group level before independent placement. Reserve actual strokes against holes, masks and bodies. Never stretch borders after collision checks. Use consistent safe clearance gaps where needed; report any individual inside placement or sizing exception. Apply semantic importance first: ESC PHASE A/B/C are prominent functional labels. Documentation sections retain ordered, left-aligned text and headings rather than scattered rows.++Pin-label alignment is target-facing, not universally centered: right-align ink for labels left of their pad, left-align for labels right of their pad. Evaluate this in the visible face orientation, reversing it for the bottom view. Store the electrical target and alignment intent, keep measured native bounds, and avoid wasted visual gap between ink and the pad. Center only when it improves an intentionally symmetric group.+@@ -0,0 +1,11 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"+ fill="none" stroke="#e6edf3" stroke-width="1.8"+ stroke-linecap="round" stroke-linejoin="round">+ <!-- THE Adom settings cog: a true line drawing (strokes, no filled silhouette).+ Hardcoded #e6edf3 because this file is loaded as its own document via+ <img src> / favicon, where currentColor would resolve to black. Inline+ copies pasted into markup may swap the stroke to currentColor. -->+ <circle cx="12" cy="12" r="3"/>+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>+</svg>+@@ -0,0 +1,150 @@+# 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.++### Release quality: references, values and visual association++Do not accept a fresh solve based on text-object coverage alone. Place each reference+and required value atomically; reference text is larger, value text is normally+60–80% of reference height. Reserve both before placing the next component. Keep+connector rows in physical pin order and preserve that constraint on both faces.+An old layout is a comparison baseline, never evidence that a new solver succeeded.++Where a nearby component could be mistaken for the label's owner, provide a short+curved pointer to the intended component's visible rim. Where space permits, a+shared outline may group the component and its reference/value. These graphics+must clear mask openings, drilled holes, other bodies and all text. Avoid extra+lines where proximity already makes ownership unambiguous. If no safe association+can be drawn, repack or flag it for review; do not silently mark it complete.++Release proof includes a solve from empty label occupancy without old positions as+candidates, name/value hierarchy and completeness checks, connector-order checks,+collision checks including fitted models and vias, native EDA readback and DRC,+and human-readable top/bottom review with the full event replay. Show bottom from+below with readable text, or explicitly mirror text in a top-view projection.++Machine contacts and machine pins use an outside-first policy: the label sits+between the contact and the nearest board edge whenever feasible. Preserve row+order and side consistency; report inside fallbacks. Printed contact/name/value+frames default on (`contactBorders: true`), with `--no-contact-borders` for a solve+without them. Reserve every frame stroke during placement, and report any small+clearance gaps required around holes. This preference applies to both board faces.++### Board-specific importance pass before layout+The AI first identifies how the real board will be connected, powered, serviced and+debugged. Rank interfaces by their importance to those tasks, using verified board,+schematic and spec data. Write that ranking into the solve input; do not give every+label equal weight. Reserve prominent connection labels first, then component+reference/value pairs and secondary service notes. Only print voltage/current+ratings supported by the design review, never guessed ratings.++ESC / BLDC controller rule: PHASE A, PHASE B and PHASE C are prominent motor-terminal+names, normally targeting 1.0–1.4 mm where geometry permits, on both service faces.+Battery input and polarity/ground also deserve prominent identification. Phase+names are primary interface labels, not small component values: their MC/connector+reference can be smaller. Preserve the mapping to the actual terminal nets; never+infer phase order from screen position. Programming/debug labels remain legible but+must not consume the space reserved for power and motor connections. Document any+geometry-driven reduction from the desired hierarchy.+++### Documentation sections are atomic+Board identity, instructions, warnings and service-reference tables must retain semantic sections. Preserve row order, a shared readable alignment, consistent line spacing and a larger heading. Place or resize the whole block; never sort individual lines by length or scatter them on generic center-column anchors. Bottom blocks align in the actual bottom-face reading orientation. Keep reference tables supplementary to labels beside physical connectors. Report an unresolved section rather than silently disassembling it. Replay one section at a time and expose its text heights.++Documentation blocks default to left-aligned visible glyphs, not merely aligned calculation boxes. Use the readable left edge on both faces, including bottom reflection. The dashboard must not recenter a shorter font rendering inside the EDA-measured box. Native exports may use equivalent centered anchors computed from measured glyph bounds, provided the actual ink shares the left edge.++### Visual consistency across connector groups+Treat repeated connector labels as a designed row or column. Prefer shared outer border rails, common widths/heights, padding, reference/value hierarchy and consistent spacing. Solve these at group level before independent placement. Reserve actual strokes against holes, masks and bodies. Never stretch borders after collision checks. Use consistent safe clearance gaps where needed; report any individual inside placement or sizing exception. Apply semantic importance first: ESC PHASE A/B/C are prominent functional labels. Documentation sections retain ordered, left-aligned text and headings rather than scattered rows.++Pin-label alignment is target-facing, not universally centered: right-align ink for labels left of their pad, left-align for labels right of their pad. Evaluate this in the visible face orientation, reversing it for the bottom view. Store the electrical target and alignment intent, keep measured native bounds, and avoid wasted visual gap between ink and the pad. Center only when it improves an intentionally symmetric group.+
Comments
Log in to comment.
The corrected retakes are complete and published: https://wiki.adom.inc/adom/esc-g431/files/docs/astra/silkscreen-full/README.md
67.07 seconds, 1920x1080, 6,345,556 bytes. Inspected the contact sheet and full frames, then verified actual authenticated Hydrogen WV playback: 1920x1080, readyState 4, paused=false, currentTime advancing, no media error. Native top/bottom footage shows v35 and the DRC remains at the 13/539 baseline. The rejected cut remains local with its capture marker.
Integration note: the development Rust wrapper source is an older snapshot and does not include all current 0.1.25 commands (for example artifact). Please port the new match arms/helpers into current source; do not replace the released main.rs wholesale. The draft status is intentional. Installed adom-aiflow 0.1.25 remains the normal run/artifact tool.