From a96471d6eba035005a2568709971d5430bdf78b1 Mon Sep 17 00:00:00 2001 From: seveibar Date: Tue, 28 Jul 2026 15:46:39 -0700 Subject: [PATCH 1/2] add configurable SMT pad pair footprint --- src/fn/index.ts | 1 + src/fn/smtpadpair.ts | 111 ++++++++++++++++++++++++ src/footprinter.ts | 12 +++ tests/__snapshots__/asymmetric.snap.svg | 1 + tests/__snapshots__/staggered.snap.svg | 1 + tests/smtpadpair.test.ts | 51 +++++++++++ 6 files changed, 177 insertions(+) create mode 100644 src/fn/smtpadpair.ts create mode 100644 tests/__snapshots__/asymmetric.snap.svg create mode 100644 tests/__snapshots__/staggered.snap.svg create mode 100644 tests/smtpadpair.test.ts diff --git a/src/fn/index.ts b/src/fn/index.ts index 667d67e6..2948b053 100644 --- a/src/fn/index.ts +++ b/src/fn/index.ts @@ -86,6 +86,7 @@ export { fpc } from "./fpc" export { smbf } from "./smbf" export { sot323 } from "./sot323" export { smtpad } from "./smtpad" +export { smtpadpair } from "./smtpadpair" export { smdpushbutton } from "./smdpushbutton" export { smdslideswitch } from "./smdslideswitch" export { smdpinheader } from "./smdpinheader" diff --git a/src/fn/smtpadpair.ts b/src/fn/smtpadpair.ts new file mode 100644 index 00000000..24f1cc55 --- /dev/null +++ b/src/fn/smtpadpair.ts @@ -0,0 +1,111 @@ +import { + type AnyCircuitElement, + type PcbCourtyardRect, + type PcbSilkscreenPath, + length, +} from "circuit-json" +import { z } from "zod" +import { rectpad } from "../helpers/rectpad" +import { type SilkscreenRef, silkscreenRef } from "../helpers/silkscreenRef" +import { base_def } from "../helpers/zod/base_def" + +const positiveLength = length.refine((value) => value > 0, { + message: "pad dimensions must be positive", +}) + +export const smtpadpair_def = base_def + .extend({ + fn: z.literal("smtpadpair"), + num_pins: z.literal(2).default(2), + px: length.default("2mm").describe("pin 2 x offset from pin 1"), + py: length.default("0mm").describe("pin 2 y offset from pin 1"), + p1w: positiveLength.default("1mm").describe("pin 1 pad width"), + p1h: positiveLength.default("1mm").describe("pin 1 pad height"), + p2w: positiveLength.default("1mm").describe("pin 2 pad width"), + p2h: positiveLength.default("1mm").describe("pin 2 pad height"), + }) + .superRefine(({ px, py }, ctx) => { + if (px === 0 && py === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "smtpadpair pads must have a non-zero center offset", + path: ["px", "py"], + }) + } + }) + +export const smtpadpair = ( + rawParams: z.input, +): { circuitJson: AnyCircuitElement[]; parameters: any } => { + const parameters = smtpadpair_def.parse(rawParams) + const { px, py, p1w, p1h, p2w, p2h } = parameters + const pin1 = { x: -px / 2, y: py === 0 ? 0 : -py / 2 } + const pin2 = { x: px / 2, y: py / 2 } + const pads = [ + rectpad(1, pin1.x, pin1.y, p1w, p1h), + rectpad(2, pin2.x, pin2.y, p2w, p2h), + ] + + const centerDistance = Math.hypot(px, py) + const outward = { + x: -px / centerDistance, + y: -py / centerDistance, + } + const perpendicular = { x: -outward.y, y: outward.x } + const pin1Radius = + Math.abs(outward.x) * (p1w / 2) + Math.abs(outward.y) * (p1h / 2) + const arrowTip = { + x: pin1.x + outward.x * (pin1Radius + 0.12), + y: pin1.y + outward.y * (pin1Radius + 0.12), + } + const arrowBaseCenter = { + x: arrowTip.x + outward.x * 0.3, + y: arrowTip.y + outward.y * 0.3, + } + const pin1Marker: PcbSilkscreenPath = { + type: "pcb_silkscreen_path", + layer: "top", + pcb_component_id: "", + pcb_silkscreen_path_id: "pin1_marker", + route: [ + { + x: arrowBaseCenter.x + perpendicular.x * 0.16, + y: arrowBaseCenter.y + perpendicular.y * 0.16, + }, + arrowTip, + { + x: arrowBaseCenter.x - perpendicular.x * 0.16, + y: arrowBaseCenter.y - perpendicular.y * 0.16, + }, + ], + stroke_width: 0.1, + } + + const copperMinX = Math.min(pin1.x - p1w / 2, pin2.x - p2w / 2) + const copperMaxX = Math.max(pin1.x + p1w / 2, pin2.x + p2w / 2) + const copperMinY = Math.min(pin1.y - p1h / 2, pin2.y - p2h / 2) + const copperMaxY = Math.max(pin1.y + p1h / 2, pin2.y + p2h / 2) + const courtyard: PcbCourtyardRect = { + type: "pcb_courtyard_rect", + pcb_courtyard_rect_id: "", + pcb_component_id: "", + center: { + x: (copperMinX + copperMaxX) / 2, + y: (copperMinY + copperMaxY) / 2, + }, + width: copperMaxX - copperMinX + 0.5, + height: copperMaxY - copperMinY + 0.5, + layer: "top", + } + const refY = outward.y > 0 ? copperMinY - 0.8 : copperMaxY + 0.8 + const ref: SilkscreenRef = silkscreenRef( + (copperMinX + copperMaxX) / 2, + refY, + 0.5, + ) + + return { + circuitJson: [...pads, pin1Marker, ref, courtyard], + parameters, + } +} diff --git a/src/footprinter.ts b/src/footprinter.ts index 73cd0595..18ba34e3 100644 --- a/src/footprinter.ts +++ b/src/footprinter.ts @@ -475,6 +475,9 @@ export type Footprinter = { soup: () => AnySoupElement[] circuitJson: () => AnyCircuitElement[] } + smtpadpair: () => FootprinterParamsBuilder< + "px" | "py" | "p1w" | "p1h" | "p2w" | "p2h" + > platedhole: () => FootprinterParamsBuilder< "d" | "hd" | "r" | "hr" | "pd" | "pr" > @@ -565,6 +568,15 @@ export const string = (def: string): Footprinter => { v: pin1LocationMatch[2], } } + const numberedPadParameterMatch = s.match( + /^(p[12][whxy])([\(\d\.\+\-\?].*)$/i, + ) + if (numberedPadParameterMatch) { + return { + fn: numberedPadParameterMatch[1]!.toLowerCase(), + v: numberedPadParameterMatch[2], + } + } const m = s.match(/([a-zA-Z]+)([\(\d\.\+\-\?].*)?/) if (!m) return null const [, rawFn, v] = m diff --git a/tests/__snapshots__/asymmetric.snap.svg b/tests/__snapshots__/asymmetric.snap.svg new file mode 100644 index 00000000..4ea5f2b7 --- /dev/null +++ b/tests/__snapshots__/asymmetric.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/staggered.snap.svg b/tests/__snapshots__/staggered.snap.svg new file mode 100644 index 00000000..b0a74d09 --- /dev/null +++ b/tests/__snapshots__/staggered.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/smtpadpair.test.ts b/tests/smtpadpair.test.ts new file mode 100644 index 00000000..8722d983 --- /dev/null +++ b/tests/smtpadpair.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { fp } from "../src/footprinter" + +const getPadGeometry = (footprint: string) => + fp + .string(footprint) + .circuitJson() + .filter((element) => element.type === "pcb_smtpad") + .map(({ x, y, width, height, port_hints }) => ({ + x, + y, + width, + height, + port_hints, + })) + +test("smtpadpair supports independently sized pads", () => { + const footprint = "smtpadpair_px2.1mm_p1w2mm_p1h1mm_p2w1.2mm_p2h1mm" + const circuitJson = fp.string(footprint).circuitJson() + + expect(getPadGeometry(footprint)).toEqual([ + { x: -1.05, y: 0, width: 2, height: 1, port_hints: ["1"] }, + { x: 1.05, y: 0, width: 1.2, height: 1, port_hints: ["2"] }, + ]) + expect(convertCircuitJsonToPcbSvg(circuitJson)).toMatchSvgSnapshot( + import.meta.path, + "asymmetric", + ) +}) + +test("smtpadpair supports a signed vertical pad offset", () => { + const footprint = + "smtpadpair_px13.45mm_py-2.54mm_p1w2.5mm_p1h2.55mm_p2w2.5mm_p2h2.55mm" + const circuitJson = fp.string(footprint).circuitJson() + + expect(getPadGeometry(footprint)).toEqual([ + { x: -6.725, y: 1.27, width: 2.5, height: 2.55, port_hints: ["1"] }, + { x: 6.725, y: -1.27, width: 2.5, height: 2.55, port_hints: ["2"] }, + ]) + expect(convertCircuitJsonToPcbSvg(circuitJson)).toMatchSvgSnapshot( + import.meta.path, + "staggered", + ) +}) + +test("smtpadpair rejects coincident pads", () => { + expect(() => fp.string("smtpadpair_px0mm_py0mm").circuitJson()).toThrow( + "non-zero center offset", + ) +}) From cd91f543c0d176b25a6a4d99ccd25a881c93d9e7 Mon Sep 17 00:00:00 2001 From: seveibar Date: Tue, 28 Jul 2026 15:48:57 -0700 Subject: [PATCH 2/2] support shared SMT pad pair dimensions --- src/fn/smtpadpair.ts | 17 +++++++++++++---- src/footprinter.ts | 2 +- tests/smtpadpair.test.ts | 5 ++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/fn/smtpadpair.ts b/src/fn/smtpadpair.ts index 24f1cc55..876668f4 100644 --- a/src/fn/smtpadpair.ts +++ b/src/fn/smtpadpair.ts @@ -19,10 +19,12 @@ export const smtpadpair_def = base_def num_pins: z.literal(2).default(2), px: length.default("2mm").describe("pin 2 x offset from pin 1"), py: length.default("0mm").describe("pin 2 y offset from pin 1"), - p1w: positiveLength.default("1mm").describe("pin 1 pad width"), - p1h: positiveLength.default("1mm").describe("pin 1 pad height"), - p2w: positiveLength.default("1mm").describe("pin 2 pad width"), - p2h: positiveLength.default("1mm").describe("pin 2 pad height"), + pw: positiveLength.default("1mm").describe("shared pad width"), + ph: positiveLength.default("1mm").describe("shared pad height"), + p1w: positiveLength.optional().describe("pin 1 pad width override"), + p1h: positiveLength.optional().describe("pin 1 pad height override"), + p2w: positiveLength.optional().describe("pin 2 pad width override"), + p2h: positiveLength.optional().describe("pin 2 pad height override"), }) .superRefine(({ px, py }, ctx) => { if (px === 0 && py === 0) { @@ -33,6 +35,13 @@ export const smtpadpair_def = base_def }) } }) + .transform((parameters) => ({ + ...parameters, + p1h: parameters.p1h ?? parameters.ph, + p1w: parameters.p1w ?? parameters.pw, + p2h: parameters.p2h ?? parameters.ph, + p2w: parameters.p2w ?? parameters.pw, + })) export const smtpadpair = ( rawParams: z.input, diff --git a/src/footprinter.ts b/src/footprinter.ts index 18ba34e3..edcba4b8 100644 --- a/src/footprinter.ts +++ b/src/footprinter.ts @@ -476,7 +476,7 @@ export type Footprinter = { circuitJson: () => AnyCircuitElement[] } smtpadpair: () => FootprinterParamsBuilder< - "px" | "py" | "p1w" | "p1h" | "p2w" | "p2h" + "px" | "py" | "pw" | "ph" | "p1w" | "p1h" | "p2w" | "p2h" > platedhole: () => FootprinterParamsBuilder< "d" | "hd" | "r" | "hr" | "pd" | "pr" diff --git a/tests/smtpadpair.test.ts b/tests/smtpadpair.test.ts index 8722d983..0221207a 100644 --- a/tests/smtpadpair.test.ts +++ b/tests/smtpadpair.test.ts @@ -16,7 +16,7 @@ const getPadGeometry = (footprint: string) => })) test("smtpadpair supports independently sized pads", () => { - const footprint = "smtpadpair_px2.1mm_p1w2mm_p1h1mm_p2w1.2mm_p2h1mm" + const footprint = "smtpadpair_px2.1mm_ph1mm_p1w2mm_p2w1.2mm" const circuitJson = fp.string(footprint).circuitJson() expect(getPadGeometry(footprint)).toEqual([ @@ -30,8 +30,7 @@ test("smtpadpair supports independently sized pads", () => { }) test("smtpadpair supports a signed vertical pad offset", () => { - const footprint = - "smtpadpair_px13.45mm_py-2.54mm_p1w2.5mm_p1h2.55mm_p2w2.5mm_p2h2.55mm" + const footprint = "smtpadpair_px13.45mm_py-2.54mm_pw2.5mm_ph2.55mm" const circuitJson = fp.string(footprint).circuitJson() expect(getPadGeometry(footprint)).toEqual([