abse/smartwatch-flex-top-only

Three-panel flex smartwatch with two parallel R3.9 bends, all components flat-top, low-profile JST connector harnesses, split heartbeat optical windows and screw-retained PCB mounting. Modeled 39 × 50.5 × 14.5mm prototype; 100% routing, zero native DRC and shorts. Hardware qualification pending.

Version
0.2.20
License
unset
Stars
0

scripts/audit-copper.mjs

import fs from 'node:fs'
import {isAuthorizedThermalVia} from './thermal-via-policy.mjs'
import {createHash} from 'node:crypto'
import {printedCoilShapes} from './copper-model.mjs'
const cachePath=process.argv[2]??'routing/freerouting/cache.json',sourcePath=process.argv[3]??'dist/extract-routing/circuit.json'
const cache=JSON.parse(fs.readFileSync(cachePath)),c=JSON.parse(fs.readFileSync(sourcePath)),layers=['top','inner1','inner2','bottom']
const groups=new Map(cache.input.connections.map(n=>[n.name,[]])),portNets=new Map(),viaNodes=[],pads=[]
for(const n of cache.input.connections)for(const p of n.pointsToConnect)portNets.set(p.pcb_port_id,n.name)
const add=(net,s)=>{groups.get(net)?.push(s);return s}
// Only the two actual component terminal leads belong to external PCB nets.
// The intervening winding never joins QI_COIL1 and QI_AC2 in this audit.
for(const lead of printedCoilShapes(c).filter(s=>s.terminalLead))add(lead.net,lead)
for(const p of c.filter(e=>e.type==='pcb_smtpad'||e.type==='pcb_plated_hole')){
 const port=c.find(e=>e.type==='pcb_port'&&e.pcb_port_id===p.pcb_port_id),x=p.x??port.x,y=p.y??port.y
 let s
 if(p.shape==='circle')s={kind:'circle',x,y,r:p.radius??p.outer_diameter/2}
 else if(p.shape==='pill'){const r=Math.min(p.width,p.height)/2,d=(Math.max(p.width,p.height)-2*r)/2;s={kind:'segment',a:{x:x-(p.width>=p.height?d:0),y:y-(p.height>p.width?d:0)},b:{x:x+(p.width>=p.height?d:0),y:y+(p.height>p.width?d:0)},r}}
 else if(p.shape==='rect')s={kind:'polygon',points:[{x:x-p.width/2,y:y-p.height/2},{x:x+p.width/2,y:y-p.height/2},{x:x+p.width/2,y:y+p.height/2},{x:x-p.width/2,y:y+p.height/2}]}
 else if(p.shape==='polygon')s={kind:'polygon',points:p.points}
 else throw Error(`Unknown pad shape ${p.shape}`)
 s={...s,id:p.pcb_smtpad_id??p.pcb_plated_hole_id,port:p.pcb_port_id,layers:p.layers??[p.layer],net:portNets.get(p.pcb_port_id)};pads.push(s);if(s.net)add(s.net,s)
}
for(const t of cache.traces){
 if(!groups.has(t.connection_name))throw Error(`No expected net for trace ${t.pcb_trace_id}`)
 for(let i=0;i<t.route.length;i++){
  const p=t.route[i],q=t.route[i+1]
  if(p.route_type==='via'){
   const s={kind:'circle',x:p.x,y:p.y,r:p.via_diameter/2,hole:p.via_hole_diameter,id:`${t.pcb_trace_id}_via${i}`,traceId:t.pcb_trace_id,layers,net:t.connection_name};viaNodes.push(s);add(t.connection_name,s)
  }
  if(p.route_type==='wire'&&q?.route_type==='wire'&&p.layer===q.layer)add(t.connection_name,{kind:'segment',a:p,b:q,r:Math.min(p.width,q.width)/2,id:`${t.pcb_trace_id}_${i}`,traceId:t.pcb_trace_id,layers:[p.layer]})
 }
}
const eps=1e-6,cross=(a,b,c)=>(b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x)
const dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y)
function ptSeg(p,a,b){const dx=b.x-a.x,dy=b.y-a.y,u=Math.max(0,Math.min(1,((p.x-a.x)*dx+(p.y-a.y)*dy)/(dx*dx+dy*dy||1)));return Math.hypot(p.x-a.x-u*dx,p.y-a.y-u*dy)}
function lineTouch(a,b,c,d){const u=cross(a,b,c),v=cross(a,b,d),w=cross(c,d,a),z=cross(c,d,b);if(((u>eps&&v<-eps)||(u<-eps&&v>eps))&&((w>eps&&z<-eps)||(w<-eps&&z>eps)))return true;return Math.min(ptSeg(a,c,d),ptSeg(b,c,d),ptSeg(c,a,b),ptSeg(d,a,b))<eps}
function segDist(a,b,c,d){return lineTouch(a,b,c,d)?0:Math.min(ptSeg(a,c,d),ptSeg(b,c,d),ptSeg(c,a,b),ptSeg(d,a,b))}
function inside(p,poly){let b=false;for(let i=0,j=poly.length-1;i<poly.length;j=i++){const a=poly[i],c=poly[j];if(ptSeg(p,a,c)<eps)return true;if((a.y>p.y)!==(c.y>p.y)&&p.x<(c.x-a.x)*(p.y-a.y)/(c.y-a.y)+a.x)b=!b}return b}
const edges=p=>p.map((v,i)=>[v,p[(i+1)%p.length]])
// Use the actual generated BREP copper, including every clearance hole. A net
// label alone never joins a pad to a plane.
for(const p of c.filter(e=>e.type==='pcb_copper_pour')){
 if(p.shape!=='brep')throw Error('Unhandled pour geometry')
 const net=p.source_net_id
 if(!groups.has(net))throw Error(`Unknown pour net ${net}`)
 add(net,{kind:'pour',outer:p.brep_shape.outer_ring.vertices,holes:p.brep_shape.inner_rings.map(r=>r.vertices),id:p.pcb_copper_pour_id,layers:[p.layer]})
}
const ringBounds=new WeakMap()
const inRingBounds=(p,r)=>{let b=ringBounds.get(r);if(!b){b={left:Math.min(...r.map(p=>p.x)),right:Math.max(...r.map(p=>p.x)),bottom:Math.min(...r.map(p=>p.y)),top:Math.max(...r.map(p=>p.y))};ringBounds.set(r,b)}return p.x>=b.left-eps&&p.x<=b.right+eps&&p.y>=b.bottom-eps&&p.y<=b.top+eps}
const pourInside=(p,s)=>inRingBounds(p,s.outer)&&inside(p,s.outer)&&!s.holes.some(h=>inRingBounds(p,h)&&inside(p,h))
const pourEdges=s=>s.contactEdges??(s.contactEdges=[s.outer,...s.holes].flatMap(edges))
function edgeGap(aa,bb){let d=Infinity;for(const [u,v]of aa)for(const[s,t]of bb){d=Math.min(d,segDist(u,v,s,t));if(d<=eps)return d}return d}
function gap(a,b){
 if(b.kind==='pour'&&a.kind!=='pour')return gap(b,a)
 if(a.kind==='pour'){
  if(b.kind==='circle')return pourInside(b,a)?-b.r:Math.min(...pourEdges(a).map(([u,v])=>ptSeg(b,u,v)))-b.r
  if(b.kind==='segment')return pourInside(b.a,a)||pourInside(b.b,a)?-b.r:Math.min(...pourEdges(a).map(([u,v])=>segDist(b.a,b.b,u,v)))-b.r
  if(b.kind==='polygon'){
   if(b.points.some(p=>pourInside(p,a))||a.outer.some(p=>inside(p,b.points)))return -1
   return edgeGap(pourEdges(a),edges(b.points))
  }
  if(b.kind==='pour'){
   if(a.outer.some(p=>pourInside(p,b))||b.outer.some(p=>pourInside(p,a)))return -1
   return edgeGap(edges(a.outer),edges(b.outer))
  }
 }
 if(a.kind==='polygon'&&b.kind!=='polygon')return gap(b,a)
 if(a.kind==='circle'&&b.kind==='circle')return dist(a,b)-a.r-b.r
 if(a.kind==='segment'&&b.kind==='circle')return ptSeg(b,a.a,a.b)-a.r-b.r
 if(a.kind==='circle'&&b.kind==='segment')return gap(b,a)
 if(a.kind==='segment'&&b.kind==='segment')return segDist(a.a,a.b,b.a,b.b)-a.r-b.r
 if(a.kind==='circle'&&b.kind==='polygon')return inside(a,b.points)?-a.r:Math.min(...edges(b.points).map(([u,v])=>ptSeg(a,u,v)))-a.r
 if(a.kind==='segment'&&b.kind==='polygon')return inside(a.a,b.points)||inside(a.b,b.points)?-a.r:Math.min(...edges(b.points).map(([u,v])=>segDist(a.a,a.b,u,v)))-a.r
 if(a.kind==='polygon'&&b.kind==='polygon'){if(inside(a.points[0],b.points)||inside(b.points[0],a.points))return -1;return Math.min(...edges(a.points).flatMap(([u,v])=>edges(b.points).map(([s,t])=>segDist(u,v,s,t))))}
 throw Error('Unhandled geometry')
}
// Disjoint bounding boxes cannot make physical contact. This acceleration
// leaves the exact polygon/capsule contact test unchanged for every candidate.
const shapeBounds=s=>{
 const pp=s.kind==='pour'?s.outer:s.kind==='polygon'?s.points:s.kind==='circle'?[s]:[s.a,s.b],r=s.r??0
 return {left:Math.min(...pp.map(p=>p.x))-r,right:Math.max(...pp.map(p=>p.x))+r,bottom:Math.min(...pp.map(p=>p.y))-r,top:Math.max(...pp.map(p=>p.y))+r}
}
const netResults=[],failures=[],orphanTraceIds=[],floatingGroundPourIds=[]
for(const [net,shapes]of groups){
 const parent=shapes.map((_,i)=>i),find=i=>parent[i]===i?i:(parent[i]=find(parent[i])),join=(a,b)=>parent[find(a)]=find(b)
 const boxes=shapes.map(shapeBounds)
 for(let i=0;i<shapes.length;i++)for(let j=i+1;j<shapes.length;j++){
  const a=boxes[i],b=boxes[j]
  if(a.right+eps<b.left||b.right+eps<a.left||a.top+eps<b.bottom||b.top+eps<a.bottom)continue
  if(find(i)===find(j))continue
  if(shapes[i].layers.some(l=>shapes[j].layers.includes(l))&&gap(shapes[i],shapes[j])<=eps)join(i,j)
 }
 const pinRoots=new Map();for(let i=0;i<shapes.length;i++)if(shapes[i].port){const r=find(i);if(!pinRoots.has(r))pinRoots.set(r,[]);pinRoots.get(r).push(shapes[i].port)}
 if(net==='source_net_0')for(let i=0;i<shapes.length;i++)if(shapes[i].kind==='pour'&&!pinRoots.has(find(i)))floatingGroundPourIds.push(shapes[i].id)
 {
  const byTrace=new Map()
  for(let i=0;i<shapes.length;i++)if(shapes[i].traceId){const id=shapes[i].traceId;byTrace.set(id,(byTrace.get(id)??false)||pinRoots.has(find(i)))}
  for(const [id,connectedToPad]of byTrace)if(!connectedToPad)orphanTraceIds.push(id)
 }
 // A required hinge strip is actual copper and must join the pad network.
 // Keep an isolated strip as a separate repair target with no invented pad
 // or logical connection; final validation requires exactly one real group.
 const requiredHinges=new Set(cache.requiredHingeTraceIds??[])
 for(let i=0;i<shapes.length;i++)if(requiredHinges.has(shapes[i].traceId)&&!pinRoots.has(find(i)))pinRoots.set(find(i),[])
 const expected=cache.input.connections.find(n=>n.name===net).pointsToConnect.map(p=>p.pcb_port_id),actual=shapes.filter(s=>s.port).map(s=>s.port)
 const missing=expected.filter(p=>!actual.includes(p)),components=[...pinRoots.values()]
 if(components.length!==1||missing.length){
  const anchors=[...pinRoots.keys()].map(root=>{
   const points=[]
   for(let i=0;i<shapes.length;i++)if(find(i)===root){
    const s=shapes[i]
    if(s.kind==='circle')for(const layer of s.layers)points.push({x:s.x,y:s.y,layer,pcb_port_id:s.port,isVia:Boolean(s.hole)})
    if(s.kind==='segment')for(const layer of s.layers){
     const count=Math.max(1,Math.ceil(dist(s.a,s.b)/.6))
     for(let j=0;j<=count;j++)points.push({x:s.a.x+(s.b.x-s.a.x)*j/count,y:s.a.y+(s.b.y-s.a.y)*j/count,layer,pcb_port_id:s.port})
    }
    if(s.kind==='polygon'&&s.port)for(const layer of s.layers)points.push({x:s.points.reduce((sum,p)=>sum+p.x,0)/s.points.length,y:s.points.reduce((sum,p)=>sum+p.y,0)/s.points.length,layer,pcb_port_id:s.port})
    if(s.kind==='pour'){
     const loX=Math.min(...s.outer.map(p=>p.x)),hiX=Math.max(...s.outer.map(p=>p.x)),loY=Math.min(...s.outer.map(p=>p.y)),hiY=Math.max(...s.outer.map(p=>p.y))
     // These are anchors on actual BREP copper already joined to this pad
     // group, including every clearance hole. A pour's net label never
     // substitutes for a geometric connection.
     const rings=[s.outer,...s.holes]
     for(let y=Math.ceil(loY);y<=hiY;y+=1)for(let x=Math.ceil(loX);x<=hiX;x+=1){
      const p={x,y}
      if(!pourInside(p,s)||rings.some(r=>edges(r).some(([a,b])=>ptSeg(p,a,b)<.075)))continue
      points.push({x,y,layer:s.layers[0],isPour:true,pourId:s.id})
     }
    }
   }
   return points
  })
  failures.push({net,disconnectedPadGroups:components,copperAnchors:anchors,missing})
 }
 netResults.push({net,padCount:actual.length,requiredPadCount:expected.length,copperShapeCount:shapes.length,connectedPadGroups:components.length,missing})
}
const drillFailures=[],viaPadFailures=[],viaSizeFailures=[];let minDrillGap=Infinity,minViaPadGap=Infinity
for(let i=0;i<viaNodes.length;i++){
 const v=viaNodes[i];if(Math.abs(v.r*2-.45)>eps||Math.abs(v.hole-.3)>eps)viaSizeFailures.push(v.id)
 for(let j=i+1;j<viaNodes.length;j++){const w=viaNodes[j],d=dist(v,w)-v.hole/2-w.hole/2;minDrillGap=Math.min(minDrillGap,d);if(d<.2-eps)drillFailures.push({a:v.id,b:w.id,drillGapMM:d})}
 // Only the nine specified filled/capped U1 EP vias may be contained in a land.
 // All other via-in-pad remains prohibited, including on the same net. Copper may join the
 // pad through an escape trace; direct annular-ring overlap is reported.
 for(const p of pads){const g=gap(v,p);if(isAuthorizedThermalVia(v,p,c,cache))continue;minViaPadGap=Math.min(minViaPadGap,g);if(g< -eps)viaPadFailures.push({via:v.id,pad:p.id,gapMM:g})}
}
const result={checkedAt:new Date().toISOString(),cachePath,cacheSha256:createHash('sha256').update(fs.readFileSync(cachePath)).digest('hex'),sourcePath,sourceSha256:createHash('sha256').update(fs.readFileSync(sourcePath)).digest('hex'),method:'Independent geometric union of actual pad polygons, routed copper capsules, all-layer through-via copper and generated BREP pours with clearance holes; no logical-net aliases used as continuity shortcuts.',netResults,connectivityFailures:failures,orphanTraceIds,floatingGroundPourIds,viaCount:viaNodes.length,viaOuterMM:.45,viaDrillMM:.3,minDrillGapMM:minDrillGap,minViaToPadCopperGapMM:minViaPadGap,drillFailures,viaPadFailures,viaSizeFailures}
fs.mkdirSync('reports',{recursive:true});fs.writeFileSync(process.argv[4]??'reports/copper-audit.json',JSON.stringify(result,null,2)+'\n')
console.log(`${netResults.length} nets: ${failures.length} disconnected; ${viaNodes.length} vias: ${drillFailures.length} hole-spacing failures, ${viaPadFailures.length} pad overlaps, ${viaSizeFailures.length} wrong sizes.`)
if(failures.length||drillFailures.length||viaPadFailures.length||viaSizeFailures.length)process.exitCode=1