diff --git a/src/fn/qfn.ts b/src/fn/qfn.ts index c4cbb5e9..8f6caac6 100644 --- a/src/fn/qfn.ts +++ b/src/fn/qfn.ts @@ -1,18 +1,49 @@ import type { AnySoupElement } from "circuit-json" -import { base_quad_def, quad, quad_def, quadTransform } from "./quad" import type { z } from "zod" +import { base_quad_def, quad, quadTransform, quad_def } from "./quad" export const qfn_def = base_quad_def.extend({}).transform(quadTransform) export const qfn = ( - parameters: z.input, + raw_params: z.input, ): { circuitJson: AnySoupElement[]; parameters: any } => { - parameters.legsoutside = false - if (!parameters.pl) { - parameters.pl = 0.875 + raw_params.legsoutside = false + const pitchValue = + typeof raw_params.p === "string" + ? Number.parseFloat(raw_params.p) + : raw_params.p + if (raw_params.pw === undefined && raw_params.pl === undefined) { + if (pitchValue !== undefined && pitchValue > 0) { + // IPC-compliant defaults: pw = 0.5 * pitch, pl based on standard QFN sizing + raw_params.pw = pitchValue * 0.5 + raw_params.pl = 0.875 + } else { + raw_params.pl = 0.875 + raw_params.pw = 0.25 + } + } else { + if (raw_params.pl === undefined) { + raw_params.pl = 0.875 + } + if (raw_params.pw === undefined) { + raw_params.pw = + pitchValue !== undefined && pitchValue > 0 ? pitchValue * 0.5 : 0.25 + } } - if (!parameters.pw) { - parameters.pw = 0.25 + + // When body dimensions (w/h) are explicitly specified and padoffset is not, + // compute padoffset so pad centers match IPC-7351B / KiCad positioning. + // QFN pads extend slightly beyond the package body edge; the pad center + // sits approximately 0.0625mm inside the body edge. + if (raw_params.padoffset === undefined && raw_params.w !== undefined) { + const pl = + typeof raw_params.pl === "string" + ? Number.parseFloat(raw_params.pl) + : raw_params.pl + if (pl !== undefined && pl > 0) { + raw_params.padoffset = 0.0625 - pl / 2 + } } - return quad(parameters) + + return quad(raw_params) } diff --git a/src/fn/quad.ts b/src/fn/quad.ts index b9993016..97ed5402 100644 --- a/src/fn/quad.ts +++ b/src/fn/quad.ts @@ -27,6 +27,7 @@ export const base_quad_def = base_def.extend({ pl: length.optional(), thermalpad: z.union([z.literal(true), dim2d]).optional(), legsoutside: z.boolean().default(false), + padoffset: z.number().optional(), }) export const quadTransform = >( @@ -81,8 +82,9 @@ export const getQuadCoords = (params: { p: number // pitch between pins pl: number // length of the pin legsoutside?: boolean + padoffset?: number }) => { - const { pin_count, pn, w, h, p, pl, legsoutside } = params + const { pin_count, pn, w, h, p, pl, legsoutside, padoffset } = params const sidePinCount = pin_count / 4 const side = SIDES_CCW[Math.floor((pn - 1) / sidePinCount)] const pos = (pn - 1) % sidePinCount @@ -95,15 +97,17 @@ export const getQuadCoords = (params: { /** pad center distance from edge (negative is inside, positive is outside) */ const pcdfe = legsoutside ? pl / 2 : -pl / 2 + const offset = padoffset ?? 0.1 + switch (side) { case "left": - return { x: -w / 2 - pcdfe + 0.1, y: ibh / 2 - pos * p, o: "vert" } + return { x: -w / 2 - pcdfe + offset, y: ibh / 2 - pos * p, o: "vert" } case "bottom": - return { x: -ibw / 2 + pos * p, y: -h / 2 - pcdfe + 0.1, o: "horz" } + return { x: -ibw / 2 + pos * p, y: -h / 2 - pcdfe + offset, o: "horz" } case "right": - return { x: w / 2 + pcdfe - 0.1, y: -ibh / 2 + pos * p, o: "vert" } + return { x: w / 2 + pcdfe - offset, y: -ibh / 2 + pos * p, o: "vert" } case "top": - return { x: ibw / 2 - pos * p, y: h / 2 + pcdfe - 0.1, o: "horz" } + return { x: ibw / 2 - pos * p, y: h / 2 + pcdfe - offset, o: "horz" } default: throw new Error("Invalid pin number") } @@ -130,6 +134,7 @@ export const quad = ( p: parameters.p ?? 0.5, pl: parameters.pl, legsoutside: parameters.legsoutside, + padoffset: parameters.padoffset, }) let pw = parameters.pw diff --git a/tests/__snapshots__/qfn32_w5_h5.snap.svg b/tests/__snapshots__/qfn32_w5_h5.snap.svg new file mode 100644 index 00000000..31d9733e --- /dev/null +++ b/tests/__snapshots__/qfn32_w5_h5.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/qfn32_w5_h5_p0.5_thermalpad.snap.svg b/tests/__snapshots__/qfn32_w5_h5_p0.5_thermalpad.snap.svg new file mode 100644 index 00000000..b364bb76 --- /dev/null +++ b/tests/__snapshots__/qfn32_w5_h5_p0.5_thermalpad.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/kicad-parity/__snapshots__/qfn32_thermalpad3.1x3.1mm.snap.svg b/tests/kicad-parity/__snapshots__/qfn32_thermalpad3.1x3.1mm.snap.svg deleted file mode 100644 index af7ff2f7..00000000 --- a/tests/kicad-parity/__snapshots__/qfn32_thermalpad3.1x3.1mm.snap.svg +++ /dev/null @@ -1 +0,0 @@ -{REF}Diff: 0.00% \ No newline at end of file diff --git a/tests/kicad-parity/__snapshots__/qfn32_thermalpad3.1x3.1mm_boolean_difference.snap.svg b/tests/kicad-parity/__snapshots__/qfn32_thermalpad3.1x3.1mm_boolean_difference.snap.svg deleted file mode 100644 index 6c5d0470..00000000 --- a/tests/kicad-parity/__snapshots__/qfn32_thermalpad3.1x3.1mm_boolean_difference.snap.svg +++ /dev/null @@ -1 +0,0 @@ -QFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm - Alignment Analysis (Footprinter vs KiCad)qfn32_thermalpad3.1x3.1mmKiCad: QFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mmPerfect alignment = complete overlap \ No newline at end of file diff --git a/tests/kicad-parity/__snapshots__/qfn32_w5_h5_p0.5_thermalpad3.1x3.1mm.snap.svg b/tests/kicad-parity/__snapshots__/qfn32_w5_h5_p0.5_thermalpad3.1x3.1mm.snap.svg new file mode 100644 index 00000000..7b869aad --- /dev/null +++ b/tests/kicad-parity/__snapshots__/qfn32_w5_h5_p0.5_thermalpad3.1x3.1mm.snap.svg @@ -0,0 +1 @@ +{REF}Diff: 0.00% \ No newline at end of file diff --git a/tests/kicad-parity/__snapshots__/qfn32_w5_h5_p0.5_thermalpad3.1x3.1mm_boolean_difference.snap.svg b/tests/kicad-parity/__snapshots__/qfn32_w5_h5_p0.5_thermalpad3.1x3.1mm_boolean_difference.snap.svg new file mode 100644 index 00000000..fcddb9c1 --- /dev/null +++ b/tests/kicad-parity/__snapshots__/qfn32_w5_h5_p0.5_thermalpad3.1x3.1mm_boolean_difference.snap.svg @@ -0,0 +1 @@ +QFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm - Alignment Analysis (Footprinter vs KiCad)qfn32_w5_h5_p0.5_thermalpad3.1x3.1mmKiCad: QFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mmPerfect alignment = complete overlap \ No newline at end of file diff --git a/tests/kicad-parity/qfn32_kicad_parity.test.ts b/tests/kicad-parity/qfn32_kicad_parity.test.ts index 2a14e443..c7bda9ea 100644 --- a/tests/kicad-parity/qfn32_kicad_parity.test.ts +++ b/tests/kicad-parity/qfn32_kicad_parity.test.ts @@ -1,21 +1,21 @@ import { expect, test } from "bun:test" -import { compareFootprinterVsKicad } from "../fixtures/compareFootprinterVsKicad" import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { compareFootprinterVsKicad } from "../fixtures/compareFootprinterVsKicad" -test("parity/qfn32_thermalpad3.1x3.1mm", async () => { +test("parity/qfn32_w5_h5_p0.5_thermalpad3.1x3.1mm", async () => { const { avgRelDiff, combinedFootprintElements, booleanDifferenceSvg } = await compareFootprinterVsKicad( - "qfn32_thermalpad3.1x3.1mm", + "qfn32_w5_h5_p0.5_thermalpad3.1x3.1mm", "Package_DFN_QFN.pretty/QFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm.circuit.json", ) const svgContent = convertCircuitJsonToPcbSvg(combinedFootprintElements) expect(svgContent).toMatchSvgSnapshot( import.meta.path, - "qfn32_thermalpad3.1x3.1mm", + "qfn32_w5_h5_p0.5_thermalpad3.1x3.1mm", ) expect(booleanDifferenceSvg).toMatchSvgSnapshot( import.meta.path, - "qfn32_thermalpad3.1x3.1mm_boolean_difference", + "qfn32_w5_h5_p0.5_thermalpad3.1x3.1mm_boolean_difference", ) }) diff --git a/tests/qfn2.test.ts b/tests/qfn2.test.ts new file mode 100644 index 00000000..5cabcdd1 --- /dev/null +++ b/tests/qfn2.test.ts @@ -0,0 +1,9 @@ +import { test, expect } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { fp } from "../src/footprinter" + +test("qfn32_w5_h5", () => { + const soup = fp.string("qfn32_w5_h5").circuitJson() + const svgContent = convertCircuitJsonToPcbSvg(soup) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, "qfn32_w5_h5") +}) diff --git a/tests/qfn3.test.ts b/tests/qfn3.test.ts new file mode 100644 index 00000000..41170647 --- /dev/null +++ b/tests/qfn3.test.ts @@ -0,0 +1,12 @@ +import { test, expect } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { fp } from "../src/footprinter" + +test("qfn32_w5_h5_p0.5_thermalpad", () => { + const soup = fp.string("qfn32_w5_h5_p0.5_thermalpad").circuitJson() + const svgContent = convertCircuitJsonToPcbSvg(soup) + expect(svgContent).toMatchSvgSnapshot( + import.meta.path, + "qfn32_w5_h5_p0.5_thermalpad", + ) +}) diff --git a/tests/qfn4.test.ts b/tests/qfn4.test.ts new file mode 100644 index 00000000..6f1b4d67 --- /dev/null +++ b/tests/qfn4.test.ts @@ -0,0 +1,8 @@ +import { test, expect } from "bun:test" +import { fp } from "../src/footprinter" + +test("qfn32 generates correct number of pads", () => { + const soup = fp.string("qfn32").circuitJson() + const pads = soup.filter((e: any) => e.type === "pcb_smtpad") + expect(pads.length).toBe(32) +}) diff --git a/tests/qfn5.test.ts b/tests/qfn5.test.ts new file mode 100644 index 00000000..49829404 --- /dev/null +++ b/tests/qfn5.test.ts @@ -0,0 +1,8 @@ +import { test, expect } from "bun:test" +import { fp } from "../src/footprinter" + +test("qfn32 with thermalpad generates 33 pads", () => { + const soup = fp.string("qfn32_thermalpad").circuitJson() + const pads = soup.filter((e: any) => e.type === "pcb_smtpad") + expect(pads.length).toBe(33) +}) diff --git a/tests/qfn6.test.ts b/tests/qfn6.test.ts new file mode 100644 index 00000000..630ea4c7 --- /dev/null +++ b/tests/qfn6.test.ts @@ -0,0 +1,11 @@ +import { test, expect } from "bun:test" +import { fp } from "../src/footprinter" + +test("qfn32 pad width scales with pitch", () => { + const soup = fp.string("qfn32_p0.4").circuitJson() + const pads = soup.filter((e: any) => e.type === "pcb_smtpad") + // With pitch 0.4, pw should be 0.4 * 0.5 = 0.2 + const firstPad = pads[0] as any + const padWidth = Math.min(firstPad.width, firstPad.height) + expect(padWidth).toBeCloseTo(0.2, 2) +})