diff --git a/cli/build/generate-kicad-footprint-library.ts b/cli/build/generate-kicad-footprint-library.ts index c4aa5a15..781e465e 100644 --- a/cli/build/generate-kicad-footprint-library.ts +++ b/cli/build/generate-kicad-footprint-library.ts @@ -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() + + 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()) +} diff --git a/lib/shared/export-snippet.ts b/lib/shared/export-snippet.ts index 3f1a157e..275406e9 100644 --- a/lib/shared/export-snippet.ts +++ b/lib/shared/export-snippet.ts @@ -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) @@ -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] @@ -48,6 +50,7 @@ const OUTPUT_EXTENSIONS: Record = { kicad_sch: ".kicad_sch", kicad_pcb: ".kicad_pcb", kicad_zip: "-kicad.zip", + "kicad-footprint-library": "-footprints.zip", } type ExportOptions = { @@ -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() + + 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) } diff --git a/tests/cli/export/export-kicad-footprint-library.test.ts b/tests/cli/export/export-kicad-footprint-library.test.ts new file mode 100644 index 00000000..d74ad117 --- /dev/null +++ b/tests/cli/export/export-kicad-footprint-library.test.ts @@ -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 () => ( + + + + + +)` + +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)