diff --git a/README.md b/README.md index 1c799d2a..eec814b3 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,27 @@ fp().sod123w().p("3.4mm").pw("0.95mm").cathodepin(1) fp().sod123w().p("3.4mm").pw("0.95mm").anodepin(1) ``` +### SOIC body dimensions + +For SOIC footprints, `bodywidth` and `bodyheight` set the physical body dimensions +used to generate the courtyard, independently of the pad positions and sizes. +Each is optional; omitted dimensions retain their existing inferred values. +These options do not resize the silkscreen. + +```ts +fp.string( + "soic8_w6.9mm_pl1.95mm_pw0.6mm_p1.27mm_bodywidth3.9mm_bodyheight4.9mm", +) + +// Builder equivalent: +fp().soic(8).w("6.9mm").pl("1.95mm").pw("0.6mm").p("1.27mm") + .bodywidth("3.9mm").bodyheight("4.9mm") +``` + +The courtyard encloses the body and copper envelopes with 0.25mm clearance. +In this example, `w` is the total pad-tip span because `legsoutside` is unset; +it is separate from the 3.9mm physical body width. + ### Rounded pads Every footprint accepts a `rounded${radius}` modifier that applies the requested diff --git a/src/fn/soic.ts b/src/fn/soic.ts index fa3488e0..de97d017 100644 --- a/src/fn/soic.ts +++ b/src/fn/soic.ts @@ -35,6 +35,22 @@ export const extendSoicDef = (newDefaults: { p: length.default(length.parse(newDefaults.p ?? "1.27mm")), pw: length.default(length.parse(newDefaults.pw ?? "0.6mm")), pl: length.default(length.parse(newDefaults.pl ?? "1.0mm")), + bodywidth: length + .refine((value) => Number.isFinite(value) && value > 0, { + message: "bodywidth must be a positive finite length", + }) + .optional() + .describe( + "physical body width used for the courtyard, independent of pad placement", + ), + bodyheight: length + .refine((value) => Number.isFinite(value) && value > 0, { + message: "bodyheight must be a positive finite length", + }) + .optional() + .describe( + "physical body height used for the courtyard, independent of the pin span", + ), legsoutside: z .boolean() .optional() @@ -110,6 +126,8 @@ export const soic = (raw_params: { soic: true num_pins: number w: number + bodywidth?: string | number + bodyheight?: string | number p?: number id?: string | number od?: string | number @@ -184,8 +202,8 @@ export const soicWithoutParsing = (parameters: z.infer) => { { x: -sw / 2, y: -sh / 2 }, ], } - const bodyHalfWidth = parameters.w / 2 - const bodyHalfHeight = sh / 2 + const bodyHalfWidth = (parameters.bodywidth ?? parameters.w) / 2 + const bodyHalfHeight = (parameters.bodyheight ?? sh) / 2 // Outer rect: wide (pad tips in X), short (pin span in Y) const courtyardStepOuterHalfWidth = @@ -197,25 +215,76 @@ export const soicWithoutParsing = (parameters: z.infer) => { Math.min(maxPadExtentX, bodyHalfWidth) + 0.25 const courtyardStepOuterHalfHeight = Math.max(maxPadExtentY, bodyHalfHeight) + 0.25 + let courtyardOutline = createRectUnionOutline([ + { + minX: -courtyardStepOuterHalfWidth, + maxX: courtyardStepOuterHalfWidth, + minY: -courtyardStepInnerHalfHeight, + maxY: courtyardStepInnerHalfHeight, + }, + { + minX: -courtyardStepInnerHalfWidth, + maxX: courtyardStepInnerHalfWidth, + minY: -courtyardStepOuterHalfHeight, + maxY: courtyardStepOuterHalfHeight, + }, + ]) + + if ( + parameters.bodywidth !== undefined || + parameters.bodyheight !== undefined + ) { + // Use the actual body and copper envelopes. Combining the minimum X of + // one with the minimum Y of the other can cut corners off a larger body. + // Include an offset thermal pad in the copper envelope as well. + if (parameters.thermalpad) { + maxPadExtentX = Math.max( + maxPadExtentX, + Math.abs(parameters.thermalpadcenteroffsetx) + + parameters.thermalpad.x / 2, + ) + maxPadExtentY = Math.max( + maxPadExtentY, + Math.abs(parameters.thermalpadcenteroffsety) + + parameters.thermalpad.y / 2, + ) + } + const envelopes = [ + { halfWidth: maxPadExtentX + 0.25, halfHeight: maxPadExtentY + 0.25 }, + { halfWidth: bodyHalfWidth + 0.25, halfHeight: bodyHalfHeight + 0.25 }, + ].sort((a, b) => b.halfWidth - a.halfWidth) + const wide = envelopes[0]! + const narrow = envelopes[1]! + if ( + wide.halfHeight >= narrow.halfHeight || + wide.halfWidth - narrow.halfWidth < 1e-9 + ) { + // A contained envelope (or a floating-point-width sliver) needs no step. + // Taking the maximum height keeps both envelopes entirely inside. + const halfHeight = Math.max(wide.halfHeight, narrow.halfHeight) + courtyardOutline = [ + { x: -wide.halfWidth, y: halfHeight }, + { x: wide.halfWidth, y: halfHeight }, + { x: wide.halfWidth, y: -halfHeight }, + { x: -wide.halfWidth, y: -halfHeight }, + ] + } else { + courtyardOutline = createRectUnionOutline( + envelopes.map(({ halfWidth, halfHeight }) => ({ + minX: -halfWidth, + maxX: halfWidth, + minY: -halfHeight, + maxY: halfHeight, + })), + ) + } + } const courtyard: PcbCourtyardOutline = { type: "pcb_courtyard_outline", pcb_courtyard_outline_id: "", pcb_component_id: "", layer: "top", - outline: createRectUnionOutline([ - { - minX: -courtyardStepOuterHalfWidth, - maxX: courtyardStepOuterHalfWidth, - minY: -courtyardStepInnerHalfHeight, - maxY: courtyardStepInnerHalfHeight, - }, - { - minX: -courtyardStepInnerHalfWidth, - maxX: courtyardStepInnerHalfWidth, - minY: -courtyardStepOuterHalfHeight, - maxY: courtyardStepOuterHalfHeight, - }, - ]), + outline: courtyardOutline, } return [ diff --git a/src/footprinter.ts b/src/footprinter.ts index cafa8f6b..326734b5 100644 --- a/src/footprinter.ts +++ b/src/footprinter.ts @@ -162,6 +162,8 @@ export type Footprinter = { num_pins?: number, ) => FootprinterParamsBuilder< | "w" + | "bodywidth" + | "bodyheight" | "p" | "pw" | "pl" diff --git a/tests/__snapshots__/soic-body-courtyard-body-encloses-pads.snap.svg b/tests/__snapshots__/soic-body-courtyard-body-encloses-pads.snap.svg new file mode 100644 index 00000000..50d311f2 --- /dev/null +++ b/tests/__snapshots__/soic-body-courtyard-body-encloses-pads.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/soic-body-courtyard-body-height-only.snap.svg b/tests/__snapshots__/soic-body-courtyard-body-height-only.snap.svg new file mode 100644 index 00000000..d6abc182 --- /dev/null +++ b/tests/__snapshots__/soic-body-courtyard-body-height-only.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/soic-body-courtyard-body-width-only.snap.svg b/tests/__snapshots__/soic-body-courtyard-body-width-only.snap.svg new file mode 100644 index 00000000..81de0190 --- /dev/null +++ b/tests/__snapshots__/soic-body-courtyard-body-width-only.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/soic-body-courtyard-explicit-body.snap.svg b/tests/__snapshots__/soic-body-courtyard-explicit-body.snap.svg new file mode 100644 index 00000000..c1a216bb --- /dev/null +++ b/tests/__snapshots__/soic-body-courtyard-explicit-body.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/soic-body-courtyard-offset-thermal-pad.snap.svg b/tests/__snapshots__/soic-body-courtyard-offset-thermal-pad.snap.svg new file mode 100644 index 00000000..20319a6a --- /dev/null +++ b/tests/__snapshots__/soic-body-courtyard-offset-thermal-pad.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/soic-body-courtyard-pads-enclose-body.snap.svg b/tests/__snapshots__/soic-body-courtyard-pads-enclose-body.snap.svg new file mode 100644 index 00000000..0c661c8b --- /dev/null +++ b/tests/__snapshots__/soic-body-courtyard-pads-enclose-body.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/kicad-parity/__snapshots__/soic8.snap.svg b/tests/kicad-parity/__snapshots__/soic8.snap.svg index 091277ee..39a8f361 100644 --- a/tests/kicad-parity/__snapshots__/soic8.snap.svg +++ b/tests/kicad-parity/__snapshots__/soic8.snap.svg @@ -1 +1 @@ -{REF}REF**Diff: 32.20% \ No newline at end of file +{REF}REF**Diff: 0.00% \ No newline at end of file diff --git a/tests/kicad-parity/__snapshots__/soic8_boolean_difference.snap.svg b/tests/kicad-parity/__snapshots__/soic8_boolean_difference.snap.svg index 8bc0d9dc..d52136da 100644 --- a/tests/kicad-parity/__snapshots__/soic8_boolean_difference.snap.svg +++ b/tests/kicad-parity/__snapshots__/soic8_boolean_difference.snap.svg @@ -1 +1 @@ -SOIC-8_3.9x4.9mm_P1.27mm - Alignment Analysis (Footprinter vs KiCad)soic8_w3.9mm_p1.27mm_legsoutsideKiCad: SOIC-8_3.9x4.9mm_P1.27mmPerfect alignment = complete overlap \ No newline at end of file +SOIC-8_3.9x4.9mm_P1.27mm - Alignment Analysis (Footprinter vs KiCad)soic8_w6.9mm_pl1.95mm_pw0.6mm_p1.27mm_bodywidth3.9mm_bodyheight4.9mmKiCad: SOIC-8_3.9x4.9mm_P1.27mmPerfect alignment = complete overlap \ No newline at end of file diff --git a/tests/kicad-parity/soic8_kicad_parity.test.ts b/tests/kicad-parity/soic8_kicad_parity.test.ts index af8dc650..a14f261b 100644 --- a/tests/kicad-parity/soic8_kicad_parity.test.ts +++ b/tests/kicad-parity/soic8_kicad_parity.test.ts @@ -3,11 +3,22 @@ import { compareFootprinterVsKicad } from "../fixtures/compareFootprinterVsKicad import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" test("parity/soic8", async () => { - const { avgRelDiff, combinedFootprintElements, booleanDifferenceSvg } = - await compareFootprinterVsKicad( - "soic8_w3.9mm_p1.27mm_legsoutside", - "Package_SO.pretty/SOIC-8_3.9x4.9mm_P1.27mm.circuit.json", - ) + // Reference body: 3.9 x 4.9mm; pads: 1.95 x 0.6mm at x = +/-2.475mm. + // Without legsoutside, w is the pad-tip span: 2 * (2.475 + 1.95 / 2) = 6.9mm. + // https://gitlab.com/kicad/libraries/kicad-footprints/-/blob/master/Package_SO.pretty/SOIC-8_3.9x4.9mm_P1.27mm.kicad_mod + const { + avgRelDiff, + courtyardDiffPercent, + combinedFootprintElements, + booleanDifferenceSvg, + } = await compareFootprinterVsKicad( + "soic8_w6.9mm_pl1.95mm_pw0.6mm_p1.27mm_bodywidth3.9mm_bodyheight4.9mm", + "Package_SO.pretty/SOIC-8_3.9x4.9mm_P1.27mm.circuit.json", + ) + + expect(avgRelDiff).toBeLessThan(0.001) + // KiCad rounds its 2.455mm pad-clearance boundary outward to 2.46mm. + expect(courtyardDiffPercent).toBeLessThan(0.1) const svgContent = convertCircuitJsonToPcbSvg(combinedFootprintElements, { showCourtyards: true, diff --git a/tests/soic-body-courtyard.test.ts b/tests/soic-body-courtyard.test.ts new file mode 100644 index 00000000..26940219 --- /dev/null +++ b/tests/soic-body-courtyard.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import type { AnyCircuitElement, PcbCourtyardOutline } from "circuit-json" +import { fp } from "../src/footprinter" + +const padDefinition = "soic8_w6.9mm_pl1.95mm_pw0.6mm_p1.27mm" + +function courtyard(elements: AnyCircuitElement[]): PcbCourtyardOutline { + const result = elements.find( + (element) => element.type === "pcb_courtyard_outline", + ) + if (!result) throw new Error("Missing SOIC courtyard") + return result +} + +function expectOutline( + elements: AnyCircuitElement[], + expected: Array<{ x: number; y: number }>, +) { + const actual = courtyard(elements).outline + expect(actual).toHaveLength(expected.length) + for (const point of expected) { + expect( + actual.some( + (candidate) => + Math.abs(candidate.x - point.x) < 1e-9 && + Math.abs(candidate.y - point.y) < 1e-9, + ), + ).toBe(true) + } +} + +function expectSnapshot(elements: AnyCircuitElement[], name: string) { + const outline = courtyard(elements).outline + // Include the whole courtyard even when it extends beyond pads and silkscreen. + const viewport = { + minX: Math.min(...outline.map((point) => point.x)) - 1, + maxX: Math.max(...outline.map((point) => point.x)) + 1, + minY: Math.min(...outline.map((point) => point.y)) - 1, + maxY: Math.max(...outline.map((point) => point.y)) + 1, + } + expect( + convertCircuitJsonToPcbSvg(elements, { showCourtyards: true, viewport }), + ).toMatchSvgSnapshot(import.meta.path, `soic-body-courtyard-${name}`) +} + +test("explicit SOIC body dimensions define the stepped courtyard without moving pads", () => { + const original = fp.string(padDefinition).circuitJson() + const elements = fp() + .soic(8) + .w("6.9mm") + .pl("1.95mm") + .pw("0.6mm") + .p("1.27mm") + .bodywidth("3.9mm") + .bodyheight("4.9mm") + .circuitJson() + + expect( + elements.filter((element) => element.type !== "pcb_courtyard_outline"), + ).toEqual( + original.filter((element) => element.type !== "pcb_courtyard_outline"), + ) + expect(elements).toEqual( + fp.string(`${padDefinition}_bodywidth3.9mm_bodyheight4.9mm`).circuitJson(), + ) + + // The body extends to (1.95, 2.45); copper to (3.45, 2.205). + // Each rectangle gets 0.25mm clearance before taking their union. + expectOutline(elements, [ + { x: -3.7, y: 2.455 }, + { x: -2.2, y: 2.455 }, + { x: -2.2, y: 2.7 }, + { x: 2.2, y: 2.7 }, + { x: 2.2, y: 2.455 }, + { x: 3.7, y: 2.455 }, + { x: 3.7, y: -2.455 }, + { x: 2.2, y: -2.455 }, + { x: 2.2, y: -2.7 }, + { x: -2.2, y: -2.7 }, + { x: -2.2, y: -2.455 }, + { x: -3.7, y: -2.455 }, + ]) + expectSnapshot(elements, "explicit-body") +}) + +test("a SOIC body larger than the pad envelope retains its corner clearance", () => { + const elements = fp + .string(`${padDefinition}_bodywidth10mm_bodyheight8mm`) + .circuitJson() + expectOutline(elements, [ + { x: -5.25, y: 4.25 }, + { x: 5.25, y: 4.25 }, + { x: 5.25, y: -4.25 }, + { x: -5.25, y: -4.25 }, + ]) + expectSnapshot(elements, "body-encloses-pads") +}) + +test("a SOIC body smaller than the pad envelope never shrinks copper clearance", () => { + const elements = fp + .string(`${padDefinition}_bodywidth1mm_bodyheight1mm`) + .circuitJson() + expectOutline(elements, [ + { x: -3.7, y: 2.455 }, + { x: 3.7, y: 2.455 }, + { x: 3.7, y: -2.455 }, + { x: -3.7, y: -2.455 }, + ]) + expectSnapshot(elements, "pads-enclose-body") +}) + +test("each SOIC body dimension can independently override its inferred value", () => { + const widthOnly = fp.string(`${padDefinition}_bodywidth3.9mm`).circuitJson() + const heightOnly = fp.string(`${padDefinition}_bodyheight8mm`).circuitJson() + const widthOutline = courtyard(widthOnly).outline + expect(Math.max(...widthOutline.map((point) => point.y))).toBeCloseTo( + 2.7725, + 8, + ) + expect(widthOutline.some((point) => Math.abs(point.x - 2.2) < 1e-9)).toBe( + true, + ) + expectOutline(heightOnly, [ + { x: -3.7, y: 4.25 }, + { x: 3.7, y: 4.25 }, + { x: 3.7, y: -4.25 }, + { x: -3.7, y: -4.25 }, + ]) + expectSnapshot(widthOnly, "body-width-only") + expectSnapshot(heightOnly, "body-height-only") +}) + +test("an explicit SOIC body courtyard still encloses an offset thermal pad", () => { + const elements = fp + .string( + `${padDefinition}_bodywidth3.9mm_bodyheight4.9mm_thermalpad2x3_thermalpadcenteroffsetx5_thermalpadcenteroffsety4`, + ) + .circuitJson() + const thermalPad = elements.find( + (element) => + element.type === "pcb_smtpad" && + element.port_hints?.includes("thermalpad"), + ) + expect(thermalPad).toMatchObject({ x: 5, y: 4, width: 2, height: 3 }) + expectOutline(elements, [ + { x: -6.25, y: 5.75 }, + { x: 6.25, y: 5.75 }, + { x: 6.25, y: -5.75 }, + { x: -6.25, y: -5.75 }, + ]) + expectSnapshot(elements, "offset-thermal-pad") +}) + +test("SOIC body dimensions reject nonpositive and nonfinite lengths", () => { + for (const dimension of ["bodywidth", "bodyheight"] as const) { + for (const value of [0, -1, Infinity, NaN]) { + expect(() => fp().soic(8)[dimension](value).circuitJson()).toThrow() + } + } +})