Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions cli/build/generate-kicad-footprint-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,39 @@ export const generateKicadFootprintLibrary = async ({
fs.writeFileSync(path.join(libraryRoot, "fp-lib-table"), libTableContent)
}
}

export type FootprintEntry = {
libraryName: string
footprintName: string
content: string
}

export const extractFootprintsFromPcb = (
pcbContent: string,
): FootprintEntry[] => {
const uniqueFootprints = new Map<string, FootprintEntry>()

try {
const parsed = parseKicadSexpr(pcbContent)
const pcb = parsed.find(
(node): node is KicadPcb => node instanceof KicadPcb,
)
if (!pcb) return []

const footprints = pcb.footprints ?? []
for (const footprint of footprints) {
const sanitized = sanitizeFootprint(footprint)
const key = `${sanitized.libraryName}::${sanitized.footprintName}`
if (!uniqueFootprints.has(key)) {
uniqueFootprints.set(key, sanitized)
}
}
} catch (error) {
console.warn(
"Failed to parse KiCad PCB content for footprint extraction:",
error,
)
}

return Array.from(uniqueFootprints.values())
}
40 changes: 40 additions & 0 deletions lib/shared/export-snippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import JSZip from "jszip"
import { generateCircuitJson } from "lib/shared/generate-circuit-json"
import type { PlatformConfig } from "@tscircuit/props"
import { extractFootprintsFromPcb } from "cli/build/generate-kicad-footprint-library"

const writeFileAsync = promisify(fs.writeFile)

Expand All @@ -31,6 +32,7 @@ export const ALLOWED_EXPORT_FORMATS = [
"kicad_sch",
"kicad_pcb",
"kicad_zip",
"kicad-footprint-library",
] as const

export type ExportFormat = (typeof ALLOWED_EXPORT_FORMATS)[number]
Expand All @@ -48,6 +50,7 @@ const OUTPUT_EXTENSIONS: Record<ExportFormat, string> = {
kicad_sch: ".kicad_sch",
kicad_pcb: ".kicad_pcb",
kicad_zip: "-kicad.zip",
"kicad-footprint-library": "-footprints.zip",
}

type ExportOptions = {
Expand Down Expand Up @@ -160,6 +163,43 @@ export const exportSnippet = async ({
outputContent = await zip.generateAsync({ type: "nodebuffer" })
break
}
case "kicad-footprint-library": {
const pcbConverter = new CircuitJsonToKicadPcbConverter(
circuitData.circuitJson,
)
pcbConverter.runUntilFinished()
const pcbContent = pcbConverter.getOutputString()

const footprintEntries = extractFootprintsFromPcb(pcbContent)

const zip = new JSZip()
const libraryNames = new Set<string>()

for (const entry of footprintEntries) {
libraryNames.add(entry.libraryName)
const libraryFolder = zip.folder(`${entry.libraryName}.pretty`)
if (libraryFolder) {
libraryFolder.file(
`${entry.footprintName}.kicad_mod`,
`${entry.content}\n`,
)
}
}

if (libraryNames.size > 0) {
const libTableEntries = Array.from(libraryNames)
.sort()
.map(
(name) =>
` (lib (name ${name}) (type KiCad) (uri \${KIPRJMOD}/${name}.pretty) (options "") (descr "Generated by tsci export"))`,
)
const libTableContent = `(fp_lib_table\n${libTableEntries.join("\n")}\n)\n`
zip.file("fp-lib-table", libTableContent)
}

outputContent = await zip.generateAsync({ type: "nodebuffer" })
break
}
default:
outputContent = JSON.stringify(circuitData.circuitJson, null, 2)
}
Expand Down
68 changes: 68 additions & 0 deletions tests/cli/export/export-kicad-footprint-library.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture"
import { test, expect } from "bun:test"
import { writeFile, readFile } from "node:fs/promises"
import path from "node:path"
import JSZip from "jszip"

const circuitCode = `
export default () => (
<board width="10mm" height="10mm">
<resistor
resistance="1k"
footprint="0402"
name="R1"
schX={3}
pcbX={3}
/>
<capacitor
capacitance="1000pF"
footprint="0402"
name="C1"
schX={-3}
pcbX={-3}
/>
<trace from=".R1 > .pin1" to=".C1 > .pin1" />
</board>
)`

test("export kicad footprint library", async () => {
const { tmpDir, runCommand } = await getCliTestFixture()
const circuitPath = path.join(tmpDir, "test-circuit.tsx")

await writeFile(circuitPath, circuitCode)

const { stderr } = await runCommand(
`tsci export ${circuitPath} -f kicad-footprint-library`,
)

expect(stderr).toBe("")

const zipBuffer = await readFile(
path.join(tmpDir, "test-circuit-footprints.zip"),
)

const zip = await JSZip.loadAsync(zipBuffer)

// Check that fp-lib-table exists
const libTable = zip.file("fp-lib-table")
expect(libTable).not.toBeNull()

const libTableContent = await libTable!.async("string")
expect(libTableContent).toContain("fp_lib_table")
expect(libTableContent).toContain("KiCad")

// Check that at least one .pretty folder with .kicad_mod file exists
const files = Object.keys(zip.files)
const prettyFolders = files.filter((f) => f.includes(".pretty/"))
expect(prettyFolders.length).toBeGreaterThan(0)

const kicadModFiles = files.filter((f) => f.endsWith(".kicad_mod"))
expect(kicadModFiles.length).toBeGreaterThan(0)

// Check content of a footprint file
const firstFootprint = zip.file(kicadModFiles[0])
expect(firstFootprint).not.toBeNull()

const footprintContent = await firstFootprint!.async("string")
expect(footprintContent).toContain("footprint")
}, 60_000)