0hmX/am3352-ddr-track-a-local-dfm-wip-20261007
Experimental RJ45-corrected board with isolated PG indication. 1005/1005 source joins pass; 15 expanded-guard physical errors, sourcing and qualification gates remain. Not fabrication-ready.
- Version
- 0.0.14
- License
- unset
- Stars
- 0
scripts/verify-board-routing.tsx
import { Circuit } from "@tscircuit/core"
import * as checks from "@tscircuit/checks"
import { getFullConnectivityMapFromCircuitJson } from "circuit-json-to-connectivity-map"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { createHash } from "node:crypto"
import { dirname, resolve } from "node:path"
import { pathToFileURL } from "node:url"
import Flatbush from "flatbush"
import { Children, cloneElement, createElement } from "react"
import Board from "../index"
// Reuse the installed checks package's exact geometry converter without modifying
// the package or guessing pad/pour geometry. Fail closed if its internals change.
const checkPath = import.meta.resolve("@tscircuit/checks").replace("file://", "")
const checkSource = readFileSync(checkPath, "utf8")
const helpers = ["convertCircuitJsonToFlattenJs", "touchesPour", "polygonContainsPoint"]
for (const helper of helpers) if (!checkSource.includes(`function ${helper}(`)) throw Error(`Checks geometry API changed: ${helper}`)
const geometryPath = resolve("node_modules/.cache/board-validation/checks-geometry.mjs")
mkdirSync(dirname(geometryPath), { recursive: true })
writeFileSync(geometryPath, `${checkSource}\nexport { ${helpers.join(", ")} };\n`)
const { convertCircuitJsonToFlattenJs: convert, touchesPour: touches } = await import(pathToFileURL(geometryPath).href)
const args=process.argv.slice(2)
const option=(name:string)=>args.includes(name)?args[args.indexOf(name)+1]:undefined
const input=option("--json")
const candidates=args.includes("--candidates")
const platedDrillPolicy=option("--plated-drill-clearance")
const platedDrillMinimum=platedDrillPolicy===undefined ? 0.2 : Number(platedDrillPolicy)
if (!Number.isFinite(platedDrillMinimum) || platedDrillMinimum<0) throw Error("Invalid --plated-drill-clearance")
if (input && candidates) throw Error("--candidates requires native TSX compilation, not --json")
const output = resolve(option("--output") ?? (args[0] && !args[0].startsWith("--") ? args[0] : "work/rest/validation"))
mkdirSync(output, { recursive: true })
const sha = (s: string) => createHash("sha256").update(s).digest("hex")
const id = (r: any) => r[`${r.type}_id`]
const copperTypes = ["pcb_smtpad", "pcb_plated_hole", "pcb_via", "pcb_trace", "pcb_copper_pour"]
const nativeNames = ["checkTracesAreContiguous", "checkEachPcbTraceNonOverlapping", "checkPadTraceClearance", "checkViaTraceClearance", "checkViaPadClearance", "checkDifferentNetViaSpacing", "checkSameNetViaSpacing", "checkPcbTracesOutOfBoard", "checkHoleTraceClearance", "checkCopperToBoardEdgeClearance", "checkViasOffBoard", "checkPadPadClearance", "checkCopperPourShorts", "checkPcbTraceSelfShorts", "checkDanglingTraces", "checkSourceTracesMatchPcbTraceThickness"]
function analyze(records: any[]) {
const logical = getFullConnectivityMapFromCircuitJson(records)
const board = records.find(r => r.type === "pcb_board")
const stack = ["top", ...Array.from({length: board.num_layers - 2}, (_, i) => `inner${i + 1}`), "bottom"]
const span = (r: any) => r.type === "pcb_hole" ? stack : stack.slice(Math.min(...r.layers.map((l: string) => stack.indexOf(l))), Math.max(...r.layers.map((l: string) => stack.indexOf(l))) + 1)
const native = Object.fromEntries(nativeNames.map(name => {
const errors=(checks as any)[name](structuredClone(records))
console.log(name,errors.length)
return [name,errors]
}))
const converted = convert(records, { elementTypes: copperTypes, strict: true, curveTolerance: 1e-6 })
const copper = converted.elements.flatMap((e: any) => e.shapes.map((shape: any) => ({...e, shape})))
const parent = copper.map((_: any, i: number) => i)
const root = (n: number): number => parent[n] === n ? n : parent[n] = root(parent[n])
const join = (a: number, b: number) => { parent[root(a)] = root(b) }
const barrels = new Map<string, number>()
const layers = new Map<string, number[]>()
copper.forEach((e: any, i: number) => {
const entries = layers.get(e.layer) ?? []; entries.push(i); layers.set(e.layer, entries)
if (["pcb_via", "pcb_plated_hole"].includes(e.elementType)) {
if (barrels.has(e.elementId)) join(i, barrels.get(e.elementId)!)
else barrels.set(e.elementId, i)
}
})
const contacts: any[] = []
const contactIndices:number[]=[]
for (const [layer, entries] of layers) {
const index = new Flatbush(entries.length)
for (const i of entries) { const b = copper[i].shape.box; index.add(b.xmin, b.ymin, b.xmax, b.ymax) }
index.finish()
for (const i of entries) {
const a = copper[i], b = a.shape.box
for (const k of index.search(b.xmin, b.ymin, b.xmax, b.ymax)) {
const j = entries[k]; if (i >= j) continue
const other = copper[j]
if (root(i) === root(j)) continue
if (!touches(a.shape, other.shape)) continue
join(i, j)
const netA = a.sourceElement.source_net_id ?? a.elementId, netB = other.sourceElement.source_net_id ?? other.elementId
if (!logical.areIdsConnected(netA, netB) && netA !== netB) { contacts.push({layer, a:a.elementId, b:other.elementId}); contactIndices.push(i) }
}
}
}
const sourcePorts = new Map(records.filter(r => r.type === "source_port").map(r => [r.source_port_id, r]))
const components = new Map(records.filter(r => r.type === "source_component").map(r => [r.source_component_id, r]))
// Manual via endpoint ports are routing artifacts, not required component pins.
const ports = records.filter(r => r.type === "pcb_port" && components.has(sourcePorts.get(r.source_port_id)?.source_component_id))
const rootsByPort = new Map<string, Set<number>>()
const shortedRoots=new Set(contactIndices.map(root))
copper.forEach((e: any, i: number) => {
const port = e.sourceElement.pcb_port_id
if (!port) return
const roots = rootsByPort.get(port) ?? new Set(); roots.add(root(i)); rootsByPort.set(port, roots)
})
const nets = records.filter(r => r.type === "source_net").map(net => {
const pins = ports.filter(p => logical.areIdsConnected(p.source_port_id, net.source_net_id)).map(p => {
const sp = sourcePorts.get(p.source_port_id), component = components.get(sp?.source_component_id)
return { port: p.pcb_port_id, selector: `${component?.name}.${sp?.name}`, roots: [...rootsByPort.get(p.pcb_port_id) ?? []] }
})
const groups = new Map<number, string[]>()
for (const p of pins) for (const key of p.roots) { const a = groups.get(key) ?? []; a.push(p.selector); groups.set(key, a) }
const largest = Math.max(0, ...[...groups.values()].map(a => a.length))
const physicallyConnected=pins.length>0 && groups.size===1 && pins.every(p=>p.roots.length>0)
const shorted=pins.some(p=>p.roots.some(key=>shortedRoots.has(key)))
return { name:net.name, pinCount:pins.length, physicalGroups:groups.size, missingCopper:pins.filter(p => !p.roots.length).map(p => p.selector), connected:physicallyConnected && !shorted, physicallyConnected, shorted, largestConnectedPinGroup:largest, connectionsNeeded:Math.max(0, groups.size - 1) + pins.filter(p => !p.roots.length).length, groups:[...groups.values()] }
})
// Supplement native checks: via/PTH/NPTH drill spacing and trace-to-plated
// drill clearance are not covered by the eight routing geometry checks.
const drills = records.filter(r => ["pcb_via", "pcb_plated_hole", "pcb_hole"].includes(r.type))
const drillRecords = drills.map(r => ({...r, type:"pcb_hole", pcb_hole_id:id(r), hole_shape:r.type === "pcb_hole" ? r.hole_shape : r.shape === "pill" ? "rotated_pill" : "circle"}))
const drillShapes = convert(drillRecords, { elementTypes:["pcb_hole"], strict:true, curveTolerance:1e-6 }).elements
const drillById = new Map(drillShapes.map((e: any) => [e.elementId, e.shapes[0]]))
const distance = (a: any, b: any) => touches(a,b) ? 0 : a.distanceTo(b)[0]
const drillSpacing: any[] = [], traceToPlatedHole: any[] = []
for (let i=0; i<drills.length; i++) for (let j=i+1; j<drills.length; j++) {
const a=drills[i], b=drills[j]
if (!span(a).some((l: string) => span(b).includes(l))) continue
// Distinct via pairs are covered by native via checks. Coincident same-net
// route-emitted records describe a shared physical drill, not two holes.
if (a.type === "pcb_via" && b.type === "pcb_via") {
if (Math.hypot(a.x-b.x,a.y-b.y)>1e-9 || logical.areIdsConnected(id(a),id(b))) continue
}
const gap=distance(drillById.get(id(a)),drillById.get(id(b)))
const minimum=a.type === "pcb_via" && b.type === "pcb_via" ? board.min_via_hole_edge_to_via_hole_edge_clearance : board.min_plated_hole_drill_edge_to_drill_edge_clearance
if (gap+1e-6<minimum) drillSpacing.push({a:id(a),b:id(b),gap,minimum})
}
for (const hole of drills.filter(r => r.type !== "pcb_hole")) {
const shape=drillById.get(id(hole)), minimum=platedDrillMinimum
for (const trace of converted.elements.filter((e: any) => e.elementType === "pcb_trace" && span(hole).includes(e.layer))) {
if (logical.areIdsConnected(id(hole),trace.elementId)) continue
const b=shape.box, c=trace.bounds
if (c.xmin>b.xmax+minimum || c.xmax<b.xmin-minimum || c.ymin>b.ymax+minimum || c.ymax<b.ymin-minimum) continue
const gap=Math.min(...trace.shapes.map((s: any) => distance(s,shape)))
if (gap+1e-6<minimum) traceToPlatedHole.push({hole:id(hole),trace:trace.elementId,layer:trace.layer,gap,minimum})
}
}
return { counts:Object.fromEntries([...copperTypes,"source_net","pcb_port","pcb_hole"].map(type => [type,records.filter(r=>r.type===type).length])), native, supplemental:{drillSpacing,traceToPlatedHole,physicalDifferentNetContacts:contacts}, connectivity:{netCount:nets.length, connectedNets:nets.filter(n=>n.connected).length, physicallyConnectedNets:nets.filter(n=>n.physicallyConnected).length, shortedNets:nets.filter(n=>n.shorted).length, unroutedNets:nets.filter(n=>!n.physicallyConnected && n.pinCount>1).length, singletonNets:nets.filter(n=>n.pinCount===1).length, nets} }
}
function failureKeys(report: any, records: any[]) {
const elements = new Map(records.map(r => [id(r),r]))
const label = (key: string): any => {
const r=elements.get(key); if (!r) return key
if (r.type === "source_component" || r.type === "source_net") return `${r.type}:${r.name}`
if (r.type === "source_port") return [label(r.source_component_id),r.name]
if (r.type === "source_trace") return [r.type,...r.connected_source_port_ids.map(label).sort(),...r.connected_source_net_ids.map(label).sort()]
if (r.type === "pcb_port") return [r.type,label(r.source_port_id),r.x,r.y,r.layers]
if (r.type === "pcb_component") return [r.type,label(r.source_component_id)]
if (r.type === "pcb_trace") return [r.type,r.route.map((p:any)=>Object.fromEntries(Object.entries(p).filter(([k])=>!k.endsWith("_id"))))]
return [r.type,r.shape,r.x,r.y,r.layer,r.layers,r.radius,r.width,r.height,r.outer_diameter,r.hole_diameter,r.outer_width,r.outer_height,r.points,r.brep_shape]
}
const stable = (value:any): any => {
if (typeof value === "string") return elements.has(value) ? stable(label(value)) : value
if (typeof value === "number") return Math.round(value*1e6)/1e6
if (Array.isArray(value)) return value.map(stable)
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().filter(k=>k!=="message" && k!=="pcb_error_id" && !k.endsWith("_error_id")).map(k=>{
const normalized=stable(value[k])
// Native checks enumerate unordered via pairs in insertion order. Compare
// their actual geometry without turning a reversed pair into a new error.
if(k==="pcb_via_ids" && Array.isArray(normalized)) normalized.sort((a,b)=>JSON.stringify(a).localeCompare(JSON.stringify(b)))
return [k,normalized]
}))
return value
}
return Object.fromEntries([...Object.entries(report.native),...Object.entries(report.supplemental)].map(([name,errors]:any)=>[name,errors.map((e:any)=>sha(JSON.stringify(stable(e))))]))
}
// Fresh checkouts validate absolutely; a saved baseline optionally enables regression comparison.
const baselinePath=option("--baseline") ?? resolve("work/rest/baseline.circuit.json")
const baselineText=args.includes("--absolute") ? "[]" : option("--baseline") || existsSync(baselinePath) ? readFileSync(baselinePath,"utf8") : "[]"
const baselineRecords=JSON.parse(baselineText)
const engineText=readFileSync(import.meta.path,"utf8").split("function analyze(")[1].split("function failureKeys(")[0]
const baselineKey=sha(baselineText+sha(checkSource)+engineText+JSON.stringify(nativeNames)+platedDrillMinimum)
const cachePath=resolve("work/rest/validation/baseline-analysis.json")
mkdirSync(dirname(cachePath),{recursive:true})
if (option("--cache-baseline-from")) {
const previous=JSON.parse(readFileSync(resolve(option("--cache-baseline-from")!),"utf8"))
if (previous.baselineSha256!==sha(baselineText) || previous.checksGeometrySha256!==sha(checkSource)) throw Error("Baseline cache provenance mismatch")
if ((previous.scope.platedDrillClearance?.minimumMm ?? 0.2)!==platedDrillMinimum) throw Error("Baseline plated-drill policy mismatch")
if (previous.baseline.supplemental.physicalDifferentNetContacts.length) throw Error("Baseline with shorts must be reanalyzed")
Object.assign(previous.baseline.connectivity,{physicallyConnectedNets:previous.baseline.connectivity.connectedNets,shortedNets:0})
for (const net of previous.baseline.connectivity.nets) Object.assign(net,{physicallyConnected:net.connected,shorted:false})
writeFileSync(cachePath,JSON.stringify({key:baselineKey,analysis:previous.baseline}))
console.log("Baseline cached",cachePath)
process.exit(0)
}
let cachedBaseline:any
try { const cache=JSON.parse(readFileSync(cachePath,"utf8")); if (cache.key===baselineKey) cachedBaseline=cache.analysis } catch {}
let records:any[]
const candidateNames=["DdrSupportRoutes","PowerRoutes","PowerTrunks","EthernetRoutes","HdmiPairRoutes","VideoRoutes","ControlRoutes","PeripheralRoutes"]
if (input) records=JSON.parse(readFileSync(resolve(input),"utf8"))
else {
const circuit = new Circuit({platform:{partsEngineDisabled:true,netlistDrcChecksDisabled:true}})
circuit.schematicDisabled = true
const board=Board(), extra=[]
if (candidates) for (const name of candidateNames) {
const module=await import(pathToFileURL(resolve(`layout/${name}.tsx`)).href)
if (!module[name]) throw Error(`Missing candidate component ${name}`)
extra.push(createElement(module[name],{key:name}))
}
circuit.add(extra.length ? cloneElement(board,{},...Children.toArray(board.props.children),...extra) : board)
const progress=setInterval(()=>console.log("Rendering",circuit.getRunningAsyncEffects().map(e=>`${e.phase}: ${e.effectName}`)),30000)
try { await circuit.renderUntilSettled() } finally { clearInterval(progress) }
records = circuit.getCircuitJson() as any[]
}
writeFileSync(`${output}/board.circuit.json`,JSON.stringify(records))
console.log(input ? "Analyzing cached native export" : "Native board compiled", records.length, "records")
console.log("Export summary",JSON.stringify({traces:records.filter(r=>r.type==="pcb_trace").length,vias:records.filter(r=>r.type==="pcb_via").length,renderErrors:records.filter(r=>r.type.endsWith("_error"))}))
const baseline=baselineRecords.length ? cachedBaseline ?? analyze(baselineRecords) : {
counts:{}, native:Object.fromEntries(nativeNames.map(name=>[name,[]])),
supplemental:{drillSpacing:[],traceToPlatedHole:[],physicalDifferentNetContacts:[]},
connectivity:{netCount:0,connectedNets:0,physicallyConnectedNets:0,shortedNets:0,unroutedNets:0,singletonNets:0,nets:[]},
}
if (!cachedBaseline) writeFileSync(cachePath,JSON.stringify({key:baselineKey,analysis:baseline}))
console.log(cachedBaseline ? "Baseline cache reused" : "Baseline analyzed")
const current=analyze(records)
const failureCounts=(report: any) => Object.fromEntries([...Object.entries(report.native),...Object.entries(report.supplemental)].map(([key,value]:any)=>[key,value.length]))
const before=failureCounts(baseline),after=failureCounts(current)
const beforeKeys=failureKeys(baseline,baselineRecords),afterKeys=failureKeys(current,records)
const report={generatedAt:new Date().toISOString(),analysisInput:input?{kind:"cached-native-export",path:resolve(input)}:{kind:"native-tsx",entry:resolve("index.tsx"),additionalComponents:candidates?candidateNames:[]},checksGeometrySha256:sha(checkSource),baselineSha256:sha(baselineText),nativeRenderErrors:records.filter(r=>r.type.endsWith("_error")),baseline,current,delta:{failures:Object.fromEntries(Object.keys(after).map(k=>[k,{baseline:before[k],current:after[k],change:after[k]-before[k],newIndices:afterKeys[k].flatMap((v:string,i:number)=>beforeKeys[k].includes(v)?[]:[i]),resolvedIndices:beforeKeys[k].flatMap((v:string,i:number)=>afterKeys[k].includes(v)?[]:[i])}])),newlyConnectedNets:current.connectivity.nets.filter(n=>n.connected && !baseline.connectivity.nets.find(b=>b.name===n.name)?.connected).map(n=>n.name)},scope:{physicalConnectivity:"Geometric copper contact on each layer, barrel bridges across actual via/PTH span, pour inner rings excluded. Does not equate a source net assignment with physical connectivity.",traceToPlatedHole:"Different-net traces versus finished via/PTH drill geometry; intentional same-net pad/via feeds exempt.",failureComparison:"Error IDs and message prose excluded; referenced elements compared by geometry and component/pin names, numeric values rounded to 1e-6 mm. New/resolved indices reference each snapshot's failure arrays.",limits:["No SI/PI, current capacity, impedance, thermal or DDR timing qualification.","Physical contact is zero-margin contact within Flatten's numerical tolerance; native checks enforce configured clearances.","Native checks package geometry internals are copied into an ignored runtime module; source hash recorded and missing functions fail closed."]}}
console.log(JSON.stringify({counts:current.counts,connectivity:{...current.connectivity,nets:undefined},failures:report.delta.failures,newlyConnectedNets:report.delta.newlyConnectedNets},null,2))
const informationalChecks=platedDrillPolicy===undefined ? ["traceToPlatedHole"] : []
Object.assign(report.scope,{informationalChecks,platedDrillClearance:{minimumMm:platedDrillMinimum,enforced:platedDrillPolicy!==undefined,contract:"minTraceToHoleEdgeClearance explicitly applies to NPTH only (installed @tscircuit/props/lib/components/group.ts). Plated-drill margin is an additional opt-in policy.",source:"https://github.com/tscircuit/props/blob/main/lib/components/group.ts"}})
writeFileSync(`${output}/report.json`,JSON.stringify(report,null,2))
if (report.nativeRenderErrors.length || Object.entries(report.delta.failures).some(([name,d])=>!informationalChecks.includes(name) && d.newIndices.length)) process.exitCode=1