tscircuit/sparkfun-boards
This code defines a detailed PCB schematic and physical footprint layout for various embedded hardware modules, mapping electrical pins, mechanical mounting holes, and silk-screen annotations for manufacturing and assembly.
- Version
- 0.0.32
- License
- unset
- Stars
- 3
scripts/convert-jlcpcb-footprints.ts
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"
import { dirname, relative, resolve } from "node:path"
import {
circuitJsonToFootprint,
circuitJsonToFootprinter,
footprinterStringToFootprint,
summarizeCopperComparison,
} from "circuit-json-to-footprinter"
import ts from "typescript"
const DEFAULT_BOARDS_DIRECTORY = "boards"
const DEFAULT_REPORT_PATH = "reports/jlcpcb-footprint-conversion-report.json"
const MATCH_THRESHOLD = 0.99
const VERIFICATION_GRID_SIZE = 320
type ConversionStatus =
| "already-string"
| "below-threshold"
| "converted"
| "error"
| "matched"
| "unsupported"
interface Options {
boardsDirectory: string
reportPath: string
write: boolean
}
interface ReportEntry {
bestFootprinterString?: string
component: string
copperIoU?: number
file: string
holeIoU?: number
jlcpcbPartNumbers: string[]
line: number
padCount?: number
reason?: string
status: ConversionStatus
}
interface Report {
generatedAt: string
options: {
boardsDirectory: string
threshold: number
write: boolean
}
parts: ReportEntry[]
summary: Record<ConversionStatus, number>
}
interface Replacement {
end: number
start: number
value: string
}
interface InlineFootprintTarget {
component: string
footprintAttribute: ts.JsxAttribute
jlcpcbPartNumbers: string[]
line: number
pads: CircuitElement[]
}
interface MatchResult {
bestFootprinterString: string | undefined
copperIoU: number
holeIoU: number
}
type CircuitElement = Record<string, unknown>
type FootprinterCircuitJson = Parameters<typeof circuitJsonToFootprinter>[0]
const discoveryCache = new Map<string, MatchResult>()
const usage = `Usage: bun run convert:jlcpcb-footprints [options]
Scans JLCPCB-backed components with inline <footprint> JSX. A component is
rewritten only when both its copper and plated-hole intersection-over-union are
at least 99%.
Options:
--write Apply qualifying replacements. The default is dry-run.
--boards-dir <path> Directory to scan (default: ${DEFAULT_BOARDS_DIRECTORY}).
--report <path> JSON report path (default: ${DEFAULT_REPORT_PATH}).
--help Show this help text.
`
const parseOptions = (args: string[]): Options => {
let boardsDirectory = DEFAULT_BOARDS_DIRECTORY
let reportPath = DEFAULT_REPORT_PATH
let write = false
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]
if (argument === "--write") {
write = true
continue
}
if (argument === "--boards-dir" || argument === "--report") {
const value = args[index + 1]
if (!value || value.startsWith("--")) {
throw new Error(`${argument} requires a path`)
}
if (argument === "--boards-dir") boardsDirectory = value
else reportPath = value
index += 1
continue
}
if (argument === "--help") {
console.log(usage)
process.exit(0)
}
throw new Error(`Unknown argument: ${argument}`)
}
return { boardsDirectory, reportPath, write }
}
const getTagName = (
openingElement: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
) => openingElement.tagName.getText()
const getAttribute = (
openingElement: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
name: string,
) => {
const attribute = openingElement.attributes.properties.find(
(property): property is ts.JsxAttribute =>
ts.isJsxAttribute(property) && property.name.getText() === name,
)
return attribute
}
const getExpression = (attribute: ts.JsxAttribute | undefined) => {
if (!attribute || !attribute.initializer) return undefined
if (ts.isStringLiteral(attribute.initializer)) return undefined
return ts.isJsxExpression(attribute.initializer)
? (attribute.initializer.expression ?? undefined)
: undefined
}
const getAttributeText = (attribute: ts.JsxAttribute | undefined) => {
if (!attribute?.initializer) return undefined
if (ts.isStringLiteral(attribute.initializer))
return attribute.initializer.text
const expression = getExpression(attribute)
return expression?.getText()
}
const getAttributeString = (
openingElement: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
name: string,
) => getAttributeText(getAttribute(openingElement, name))
const parseLength = (value: string | undefined) => {
if (!value) return undefined
const normalized = value.trim().replace(/^\{/, "").replace(/\}$/, "")
const match = normalized.match(
/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*(mm|mil|in)?$/i,
)
if (!match) return undefined
const number = Number(match[1])
if (!Number.isFinite(number)) return undefined
const unit = match[2]?.toLowerCase()
if (unit === "mil") return number * 0.0254
if (unit === "in") return number * 25.4
return number
}
const getNumberAttribute = (
openingElement: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
names: string[],
) => {
for (const name of names) {
const value = parseLength(getAttributeString(openingElement, name))
if (value !== undefined) return value
}
return undefined
}
const getPortHints = (
openingElement: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
) => {
const text = getAttributeString(openingElement, "portHints")
if (!text) return []
return [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1])
}
const getSupplierPartNumbers = (
openingElement: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
) => {
const expression = getExpression(
getAttribute(openingElement, "supplierPartNumbers"),
)
if (!expression || !ts.isObjectLiteralExpression(expression)) return []
const jlcpcbProperty = expression.properties.find((property) => {
if (!ts.isPropertyAssignment(property)) return false
return property.name.getText() === "jlcpcb"
})
if (!jlcpcbProperty || !ts.isPropertyAssignment(jlcpcbProperty)) return []
if (!ts.isArrayLiteralExpression(jlcpcbProperty.initializer)) return []
return jlcpcbProperty.initializer.elements.flatMap((element) =>
ts.isStringLiteral(element) || ts.isNoSubstitutionTemplateLiteral(element)
? [element.text]
: [],
)
}
const getFootprintElement = (
footprintAttribute: ts.JsxAttribute | undefined,
) => {
const expression = getExpression(footprintAttribute)
if (!expression || !ts.isJsxElement(expression)) return undefined
return getTagName(expression.openingElement) === "footprint"
? expression
: undefined
}
const getPadElement = (
openingElement: ts.JsxOpeningElement | ts.JsxSelfClosingElement,
index: number,
) => {
const tagName = getTagName(openingElement)
const x = getNumberAttribute(openingElement, ["pcbX", "x"])
const y = getNumberAttribute(openingElement, ["pcbY", "y"])
if (x === undefined || y === undefined) return undefined
const portHints = getPortHints(openingElement)
const rotation = getNumberAttribute(openingElement, [
"pcbRotation",
"ccwRotation",
"rotation",
])
const shape = getAttributeString(openingElement, "shape")
if (tagName === "smtpad") {
const radius = getNumberAttribute(openingElement, ["radius"])
const width =
getNumberAttribute(openingElement, ["width"]) ??
(radius === undefined ? undefined : radius * 2)
const height = getNumberAttribute(openingElement, ["height"]) ?? width
if (width === undefined || height === undefined) return undefined
return {
ccw_rotation: rotation,
height,
pcb_smtpad_id: `source_smtpad_${index + 1}`,
port_hints: portHints,
radius,
shape,
type: "pcb_smtpad",
width,
x,
y,
} satisfies CircuitElement
}
if (tagName !== "platedhole") return undefined
const outerDiameter = getNumberAttribute(openingElement, [
"outerDiameter",
"outer_diameter",
])
const outerWidth =
getNumberAttribute(openingElement, [
"outerWidth",
"outer_width",
"width",
]) ?? outerDiameter
const outerHeight =
getNumberAttribute(openingElement, [
"outerHeight",
"outer_height",
"height",
]) ?? outerWidth
if (outerWidth === undefined || outerHeight === undefined) return undefined
return {
ccw_rotation: rotation,
hole_diameter: getNumberAttribute(openingElement, [
"holeDiameter",
"hole_diameter",
]),
hole_height: getNumberAttribute(openingElement, [
"holeHeight",
"hole_height",
]),
hole_width: getNumberAttribute(openingElement, ["holeWidth", "hole_width"]),
outer_diameter: outerDiameter,
outer_height: outerHeight,
outer_width: outerWidth,
pcb_plated_hole_id: `source_platedhole_${index + 1}`,
port_hints: portHints,
shape,
type: "pcb_plated_hole",
x,
y,
} satisfies CircuitElement
}
const getFootprintPads = (footprint: ts.JsxElement) => {
const pads: CircuitElement[] = []
let ignoredPadCount = 0
const visit = (node: ts.Node) => {
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
const tagName = getTagName(node)
if (tagName === "smtpad" || tagName === "platedhole") {
const pad = getPadElement(node, pads.length + ignoredPadCount)
if (pad) pads.push(pad)
else ignoredPadCount += 1
}
}
ts.forEachChild(node, visit)
}
for (const child of footprint.children) visit(child)
return { ignoredPadCount, pads }
}
const getInlineFootprintTargets = (sourceFile: ts.SourceFile) => {
const targets: InlineFootprintTarget[] = []
const visit = (node: ts.Node) => {
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
const openingElement = ts.isJsxElement(node) ? node.openingElement : node
const jlcpcbPartNumbers = getSupplierPartNumbers(openingElement)
const footprintAttribute = getAttribute(openingElement, "footprint")
const footprint = getFootprintElement(footprintAttribute)
if (jlcpcbPartNumbers.length > 0 && footprint && footprintAttribute) {
const { ignoredPadCount, pads } = getFootprintPads(footprint)
const manufacturerPartNumber = getAttributeString(
openingElement,
"manufacturerPartNumber",
)
targets.push({
component:
manufacturerPartNumber ?? jlcpcbPartNumbers.join(", ") ?? "unknown",
footprintAttribute,
jlcpcbPartNumbers,
line:
sourceFile.getLineAndCharacterOfPosition(
openingElement.getStart(sourceFile),
).line + 1,
pads,
})
if (ignoredPadCount > 0) {
// The padded target remains intentionally conservative: a target with
// incomplete geometry is reported as unsupported below.
pads.splice(0, pads.length)
}
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return targets
}
const getExistingStringEntries = (sourceFile: ts.SourceFile, file: string) => {
const entries: ReportEntry[] = []
const visit = (node: ts.Node) => {
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
const openingElement = ts.isJsxElement(node) ? node.openingElement : node
const jlcpcbPartNumbers = getSupplierPartNumbers(openingElement)
const footprintAttribute = getAttribute(openingElement, "footprint")
const footprintText = getAttributeText(footprintAttribute)
const isStringFootprint =
!!footprintAttribute &&
(!!(
footprintAttribute.initializer &&
ts.isStringLiteral(footprintAttribute.initializer)
) ||
/^\s*["'][^"']+["']\s*$/.test(footprintText ?? ""))
if (jlcpcbPartNumbers.length > 0 && isStringFootprint) {
entries.push({
component:
getAttributeString(openingElement, "manufacturerPartNumber") ??
jlcpcbPartNumbers.join(", "),
file,
jlcpcbPartNumbers,
line:
sourceFile.getLineAndCharacterOfPosition(
openingElement.getStart(sourceFile),
).line + 1,
reason: "already uses a footprint string",
status: "already-string",
})
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return entries
}
const findTsxFiles = async (directory: string): Promise<string[]> => {
const entries = await readdir(directory, { withFileTypes: true })
const files = await Promise.all(
entries.map(async (entry) => {
const path = resolve(directory, entry.name)
if (entry.isDirectory()) return findTsxFiles(path)
return entry.isFile() && (path.endsWith(".tsx") || path.endsWith(".ts"))
? [path]
: []
}),
)
return files.flat().sort()
}
const round = (value: number) => Number(value.toFixed(6))
const findBestMatch = (
pads: CircuitElement[],
sourceHints: string[],
): MatchResult => {
const cacheKey = JSON.stringify({ pads, sourceHints })
const cached = discoveryCache.get(cacheKey)
if (cached) return cached
const input = pads as unknown as FootprinterCircuitJson
const sourceFootprint = circuitJsonToFootprint(input, { sourceHints })
const result = circuitJsonToFootprinter(input, {
maxCandidates: 1,
sourceHints,
})
const bestFootprinterString = result.best?.footprinterString
if (!bestFootprinterString) {
const noMatch = { bestFootprinterString, copperIoU: 0, holeIoU: 0 }
discoveryCache.set(cacheKey, noMatch)
return noMatch
}
// Discovery uses its fast comparison to rank candidates. Confirm the winner
// at a denser grid before replacing source code.
const comparison = summarizeCopperComparison(
sourceFootprint,
footprinterStringToFootprint(bestFootprinterString),
VERIFICATION_GRID_SIZE,
)
const match = {
bestFootprinterString,
copperIoU: round(comparison.copperIntersectionOverUnion),
holeIoU: round(comparison.holeIntersectionOverUnion),
}
discoveryCache.set(cacheKey, match)
return match
}
const createReport = (options: Options, parts: ReportEntry[]): Report => {
const summary: Record<ConversionStatus, number> = {
"already-string": 0,
"below-threshold": 0,
converted: 0,
error: 0,
matched: 0,
unsupported: 0,
}
for (const part of parts) summary[part.status] += 1
return {
generatedAt: new Date().toISOString(),
options: {
boardsDirectory: options.boardsDirectory,
threshold: MATCH_THRESHOLD,
write: options.write,
},
parts,
summary,
}
}
const main = async () => {
const options = parseOptions(process.argv.slice(2))
const workingDirectory = process.cwd()
const boardsDirectory = resolve(workingDirectory, options.boardsDirectory)
const reportPath = resolve(workingDirectory, options.reportPath)
const files = await findTsxFiles(boardsDirectory)
const reportEntries: ReportEntry[] = []
for (const file of files) {
const source = await readFile(file, "utf8")
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
)
const displayFile = relative(workingDirectory, file)
const replacements: Replacement[] = []
reportEntries.push(...getExistingStringEntries(sourceFile, displayFile))
for (const target of getInlineFootprintTargets(sourceFile)) {
if (target.pads.length === 0) {
reportEntries.push({
component: target.component,
file: displayFile,
jlcpcbPartNumbers: target.jlcpcbPartNumbers,
line: target.line,
reason:
"inline footprint contains unsupported or incomplete pad geometry",
status: "unsupported",
})
continue
}
try {
const match = findBestMatch(target.pads, [
...target.jlcpcbPartNumbers,
target.component,
])
const isMatch =
match.copperIoU >= MATCH_THRESHOLD && match.holeIoU >= MATCH_THRESHOLD
const entry: ReportEntry = {
bestFootprinterString: match.bestFootprinterString,
component: target.component,
copperIoU: match.copperIoU,
file: displayFile,
holeIoU: match.holeIoU,
jlcpcbPartNumbers: target.jlcpcbPartNumbers,
line: target.line,
padCount: target.pads.length,
status: isMatch
? options.write
? "converted"
: "matched"
: "below-threshold",
}
reportEntries.push(entry)
if (options.write && isMatch && match.bestFootprinterString) {
replacements.push({
end: target.footprintAttribute.getEnd(),
start: target.footprintAttribute.getStart(sourceFile),
value: `footprint=${JSON.stringify(match.bestFootprinterString)}`,
})
}
} catch (error) {
reportEntries.push({
component: target.component,
file: displayFile,
jlcpcbPartNumbers: target.jlcpcbPartNumbers,
line: target.line,
padCount: target.pads.length,
reason: error instanceof Error ? error.message : String(error),
status: "error",
})
}
}
if (options.write && replacements.length > 0) {
const updatedSource = replacements
.sort((left, right) => right.start - left.start)
.reduce(
(text, replacement) =>
`${text.slice(0, replacement.start)}${replacement.value}${text.slice(replacement.end)}`,
source,
)
await writeFile(file, updatedSource)
}
}
const report = createReport(options, reportEntries)
await mkdir(dirname(reportPath), { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`)
console.table(
report.parts
.filter(
(part) =>
part.status === "below-threshold" ||
part.status === "converted" ||
part.status === "matched" ||
part.status === "error",
)
.map((part) => ({
component: part.component,
copperIoU: part.copperIoU,
file: part.file,
footprinter: part.bestFootprinterString,
holeIoU: part.holeIoU,
status: part.status,
})),
)
console.log(`Wrote report: ${relative(workingDirectory, reportPath)}`)
console.log("Summary:", report.summary)
}
try {
await main()
} catch (error) {
console.error(error instanceof Error ? error.stack : error)
process.exitCode = 1
}