-
Notifications
You must be signed in to change notification settings - Fork 63
feat: implement customsmd3 footprint for custom 3-pad SMD layouts #536
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| import { z } from "zod" | ||
| import { length } from "circuit-json" | ||
| import { rectpad } from "../helpers/rectpad" | ||
| import { circlepad } from "../helpers/circlepad" | ||
| import { silkscreenRef } from "../helpers/silkscreenRef" | ||
| import type { AnyCircuitElement, PcbCourtyardRect } from "circuit-json" | ||
| import { mm } from "@tscircuit/mm" | ||
| import { base_def } from "../helpers/zod/base_def" | ||
|
|
||
| /** | ||
| * customsmd3 — a flexible 3-pad SMD footprint for custom layouts. | ||
| * | ||
| * Default layout: pin 1 on the left, pin 2 top-right, pin 3 bottom-right | ||
| * (like SOT-23 style packages). | ||
| * | ||
| * Parameters: | ||
| * - w / h: pad width / height (rect pads) | ||
| * - r: pad radius (circular pads, overrides w/h shape) | ||
| * - sym: symmetric (mirrors left/right) | ||
| * - eqsz: all pads use the same size | ||
| * - leftmostn / rightmostn / topmostn / bottommostn: specify which pin | ||
| * number occupies that extreme position | ||
| * - c2cvert / c2chorz: center-to-center distance (vertical / horizontal) | ||
| * with embedded pin spec e.g. c2cvert(1,2) → distance between pin 1 and 2 | ||
| * - e2evert / e2ehorz: edge-to-edge distance (vertical / horizontal) | ||
| * | ||
| * String format examples: | ||
| * customsmd3_w1.5_h1_leftmostn1_topmostn2_bottommostn3 | ||
| * customsmd3_r0.5_sym | ||
| * customsmd3_w1.5_h1_c2cvert(2,3)1.9_e2ehorz(1,2)2.3 | ||
| */ | ||
|
|
||
| export const customsmd3_def = base_def.extend({ | ||
| fn: z.string(), | ||
| w: length.optional(), | ||
| h: length.optional(), | ||
| r: length.optional(), | ||
| sym: z.boolean().optional(), | ||
| eqsz: z.boolean().optional(), | ||
|
|
||
| // Pin position specifiers | ||
| leftmostn: z.union([z.number(), z.string().transform(Number)]).optional(), | ||
| rightmostn: z.union([z.number(), z.string().transform(Number)]).optional(), | ||
| topmostn: z.union([z.number(), z.string().transform(Number)]).optional(), | ||
| bottommostn: z.union([z.number(), z.string().transform(Number)]).optional(), | ||
|
|
||
| // Center-to-center / edge-to-edge distances | ||
| // These can be set via API directly or parsed from the string representation | ||
| c2cvert_1_2: length.optional(), | ||
| c2cvert_1_3: length.optional(), | ||
| c2cvert_2_3: length.optional(), | ||
| c2chorz_1_2: length.optional(), | ||
| c2chorz_1_3: length.optional(), | ||
| c2chorz_2_3: length.optional(), | ||
| e2evert_1_2: length.optional(), | ||
| e2evert_1_3: length.optional(), | ||
| e2evert_2_3: length.optional(), | ||
| e2ehorz_1_2: length.optional(), | ||
| e2ehorz_1_3: length.optional(), | ||
| e2ehorz_2_3: length.optional(), | ||
|
|
||
| string: z.string().optional(), | ||
| }) | ||
|
|
||
| export type CustomSmd3Def = z.input<typeof customsmd3_def> | ||
|
|
||
| /** | ||
| * Parse distance specifiers from the raw string, e.g.: | ||
| * c2cvert(2,3)1.9 → { key: "c2cvert_2_3", value: "1.9mm" } | ||
| * e2ehorz(1,2)2.3mm → { key: "e2ehorz_1_2", value: "2.3mm" } | ||
| */ | ||
| function parseDistanceSpecifiers(s: string): Record<string, string> { | ||
| const result: Record<string, string> = {} | ||
| // Match patterns like c2cvert(2,3)1.9 or e2ehorz(1,2)2.3mm | ||
| const re = /(c2cvert|c2chorz|e2evert|e2ehorz)\((\d+),(\d+)\)([\d.]+)(mm)?/gi | ||
| let m: RegExpExecArray | null | ||
| while ((m = re.exec(s)) !== null) { | ||
| const [, type, a, b, val, unit] = m | ||
| const key = `${type.toLowerCase()}_${a}_${b}` | ||
| result[key] = `${val}${unit ?? "mm"}` | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| export const customsmd3 = ( | ||
| raw_params: CustomSmd3Def, | ||
| ): { circuitJson: AnyCircuitElement[]; parameters: any } => { | ||
| // Merge any distance specifiers extracted from the string | ||
| const extraFromString = raw_params.string | ||
| ? parseDistanceSpecifiers(raw_params.string) | ||
| : {} | ||
|
|
||
| const merged = { ...raw_params, ...extraFromString } | ||
| const params = customsmd3_def.parse(merged) | ||
|
|
||
| // --- Pad geometry --- | ||
| const isCircle = params.r !== undefined | ||
| const radius = isCircle ? mm(params.r!) : 0 | ||
| const padW = | ||
| params.w !== undefined ? mm(params.w) : isCircle ? radius * 2 : 1.5 | ||
| const padH = | ||
| params.h !== undefined ? mm(params.h) : isCircle ? radius * 2 : padW * 0.8 | ||
|
|
||
| // --- Position specifiers (defaults: 1=left, 2=top-right, 3=bottom-right) --- | ||
| const leftPin = params.leftmostn !== undefined ? params.leftmostn : 1 | ||
| const topPin = params.topmostn !== undefined ? params.topmostn : 2 | ||
| const bottomPin = params.bottommostn !== undefined ? params.bottommostn : 3 | ||
|
|
||
| // Determine which pins form the "right cluster" (top+bottom) vs "left" single pin | ||
| const rightPins = [topPin, bottomPin] | ||
|
|
||
| // --- Vertical distance between top and bottom right-side pins --- | ||
| let vertDist: number | ||
| const vertKey = `c2cvert_${Math.min(topPin, bottomPin)}_${Math.max(topPin, bottomPin)}` | ||
| if ((params as any)[vertKey] !== undefined) { | ||
| vertDist = mm((params as any)[vertKey]) | ||
| } else if (params.c2cvert_2_3 !== undefined) { | ||
| vertDist = mm(params.c2cvert_2_3) | ||
| } else if (params.c2cvert_1_2 !== undefined) { | ||
| vertDist = mm(params.c2cvert_1_2) | ||
| } else if (params.c2cvert_1_3 !== undefined) { | ||
| vertDist = mm(params.c2cvert_1_3) | ||
| } else if (params.e2evert_2_3 !== undefined) { | ||
| vertDist = mm(params.e2evert_2_3) + padH | ||
| } else if (params.e2evert_1_2 !== undefined) { | ||
| vertDist = mm(params.e2evert_1_2) + padH | ||
| } else { | ||
| vertDist = padH + 0.5 | ||
| } | ||
|
|
||
| // --- Horizontal distance between left pin and right pins --- | ||
| let horizDist: number | ||
| // edge-to-edge between pins in the two groups | ||
| if (params.e2ehorz_1_2 !== undefined) { | ||
| horizDist = mm(params.e2ehorz_1_2) + padW | ||
| } else if (params.e2ehorz_1_3 !== undefined) { | ||
| horizDist = mm(params.e2ehorz_1_3) + padW | ||
| } else if (params.c2chorz_1_2 !== undefined) { | ||
| horizDist = mm(params.c2chorz_1_2) | ||
| } else if (params.c2chorz_1_3 !== undefined) { | ||
| horizDist = mm(params.c2chorz_1_3) | ||
| } else { | ||
| horizDist = padW + 1.5 | ||
| } | ||
|
|
||
| // --- Assign x/y to each pin --- | ||
| const positions: Record<number, { x: number; y: number }> = { | ||
| [leftPin]: { x: -horizDist / 2, y: 0 }, | ||
| [topPin]: { x: horizDist / 2, y: vertDist / 2 }, | ||
| [bottomPin]: { x: horizDist / 2, y: -vertDist / 2 }, | ||
| } | ||
|
|
||
| // sym: mirror so left side also has a matching rightmost pin | ||
| if (params.sym && params.rightmostn !== undefined) { | ||
| positions[params.rightmostn] = { | ||
| x: horizDist / 2, | ||
| y: 0, | ||
| } | ||
| } | ||
|
|
||
| // --- Build pads --- | ||
| const pads: AnyCircuitElement[] = [] | ||
| for (let pinNum = 1; pinNum <= 3; pinNum++) { | ||
| const pos = positions[pinNum] | ||
| if (!pos) continue | ||
| if (isCircle) { | ||
| pads.push( | ||
| circlepad(pinNum, { | ||
| x: pos.x, | ||
| y: pos.y, | ||
| radius, | ||
| }) as AnyCircuitElement, | ||
| ) | ||
| } else { | ||
| pads.push(rectpad(pinNum, pos.x, pos.y, padW, padH) as AnyCircuitElement) | ||
| } | ||
| } | ||
|
Comment on lines
+163
to
+177
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The pad creation loop hardcodes Fix: Add validation in the schema or after parsing: if (leftPin < 1 || leftPin > 3 || topPin < 1 || topPin > 3 || bottomPin < 1 || bottomPin > 3) {
throw new Error('Pin position specifiers must be 1, 2, or 3')
}Spotted by Graphite |
||
|
|
||
| // --- Courtyard --- | ||
| const allX = Object.values(positions).map((p) => p.x) | ||
| const allY = Object.values(positions).map((p) => p.y) | ||
| const halfW = isCircle ? radius : padW / 2 | ||
| const halfH = isCircle ? radius : padH / 2 | ||
| const courtyardPadding = 0.25 | ||
| const crtMinX = Math.min(...allX) - halfW - courtyardPadding | ||
| const crtMaxX = Math.max(...allX) + halfW + courtyardPadding | ||
| const crtMinY = Math.min(...allY) - halfH - courtyardPadding | ||
| const crtMaxY = Math.max(...allY) + halfH + courtyardPadding | ||
|
|
||
| const courtyard: PcbCourtyardRect = { | ||
| type: "pcb_courtyard_rect", | ||
| pcb_courtyard_rect_id: "", | ||
| pcb_component_id: "", | ||
| center: { | ||
| x: (crtMinX + crtMaxX) / 2, | ||
| y: (crtMinY + crtMaxY) / 2, | ||
| }, | ||
| width: crtMaxX - crtMinX, | ||
| height: crtMaxY - crtMinY, | ||
| layer: "top", | ||
| } | ||
|
|
||
| const silkscreenRefText = silkscreenRef(0, crtMaxY + 0.1, 0.3) | ||
|
|
||
| return { | ||
| circuitJson: [...pads, silkscreenRefText as AnyCircuitElement, courtyard], | ||
| parameters: params, | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { expect, test } from "bun:test" | ||
| import { fp } from "../src/footprinter" | ||
| import { getTestFixture } from "./fixtures/get-test-fixture" | ||
|
|
||
| test("customsmd3 default layout (1 left, 2 top-right, 3 bottom-right)", async () => { | ||
| const { snapshotSoup } = await getTestFixture("customsmd3_default") | ||
| const circuitJson = fp().customsmd3().circuitJson() | ||
| snapshotSoup(circuitJson) | ||
|
|
||
| const pads = circuitJson.filter((el: any) => el.type === "pcb_smtpad") | ||
| expect(pads).toHaveLength(3) | ||
|
|
||
| // Pin 1 should be leftmost | ||
| const pin1 = pads.find((p: any) => p.port_hints?.includes("1"))! | ||
| const pin2 = pads.find((p: any) => p.port_hints?.includes("2"))! | ||
| const pin3 = pads.find((p: any) => p.port_hints?.includes("3"))! | ||
|
|
||
| expect(pin1.x).toBeLessThan(pin2.x) | ||
| expect(pin1.x).toBeLessThan(pin3.x) | ||
|
|
||
| // Pin 2 should be above pin 3 | ||
| expect(pin2.y).toBeGreaterThan(pin3.y) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { expect, test } from "bun:test" | ||
| import { fp } from "../src/footprinter" | ||
| import { getTestFixture } from "./fixtures/get-test-fixture" | ||
|
|
||
| test("customsmd3 with custom pad size w/h", async () => { | ||
| const { snapshotSoup } = await getTestFixture("customsmd3_w2_h1") | ||
| const circuitJson = fp().customsmd3().w("2mm").h("1mm").circuitJson() | ||
| snapshotSoup(circuitJson) | ||
|
|
||
| const pads = circuitJson.filter((el: any) => el.type === "pcb_smtpad") | ||
| expect(pads).toHaveLength(3) | ||
| for (const pad of pads) { | ||
| expect(pad.width).toBeCloseTo(2) | ||
| expect(pad.height).toBeCloseTo(1) | ||
| } | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { expect, test } from "bun:test" | ||
| import { fp } from "../src/footprinter" | ||
| import { getTestFixture } from "./fixtures/get-test-fixture" | ||
|
|
||
| test("customsmd3 circular pads with radius", async () => { | ||
| const { snapshotSoup } = await getTestFixture("customsmd3_r0.5") | ||
| const circuitJson = fp().customsmd3().r("0.5mm").circuitJson() | ||
| snapshotSoup(circuitJson) | ||
|
|
||
| const pads = circuitJson.filter((el: any) => el.type === "pcb_smtpad") | ||
| expect(pads).toHaveLength(3) | ||
| for (const pad of pads) { | ||
| expect(pad.shape).toBe("circle") | ||
| expect(pad.radius).toBeCloseTo(0.5) | ||
| } | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { expect, test } from "bun:test" | ||
| import { fp } from "../src/footprinter" | ||
| import { getTestFixture } from "./fixtures/get-test-fixture" | ||
|
|
||
| test("customsmd3 with position specifiers (leftmost=2)", async () => { | ||
| const { snapshotSoup } = await getTestFixture("customsmd3_leftmostn2") | ||
| const circuitJson = fp() | ||
| .customsmd3() | ||
| .leftmostn(2) | ||
| .topmostn(1) | ||
| .bottommostn(3) | ||
| .circuitJson() | ||
| snapshotSoup(circuitJson) | ||
|
|
||
| const pads = circuitJson.filter((el: any) => el.type === "pcb_smtpad") | ||
| const pin2 = pads.find((p: any) => p.port_hints?.includes("2"))! | ||
| const pin1 = pads.find((p: any) => p.port_hints?.includes("1"))! | ||
|
|
||
| // Pin 2 is leftmost, so its x should be less than pin 1 | ||
| expect(pin2.x).toBeLessThan(pin1.x) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { expect, test } from "bun:test" | ||
| import { fp } from "../src/footprinter" | ||
| import { getTestFixture } from "./fixtures/get-test-fixture" | ||
|
|
||
| test("customsmd3 with c2cvert (center-to-center vertical distance)", async () => { | ||
| const { snapshotSoup } = await getTestFixture("customsmd3_c2cvert_2_3_2mm") | ||
| const circuitJson = fp().customsmd3().c2cvert_2_3("2mm").circuitJson() | ||
| snapshotSoup(circuitJson) | ||
|
|
||
| const pads = circuitJson.filter((el: any) => el.type === "pcb_smtpad") | ||
| const pin2 = pads.find((p: any) => p.port_hints?.includes("2"))! | ||
| const pin3 = pads.find((p: any) => p.port_hints?.includes("3"))! | ||
|
|
||
| expect(Math.abs(pin2.y - pin3.y)).toBeCloseTo(2) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No validation that
leftmostn,topmostn, andbottommostnare distinct values. If a user sets the same pin number for multiple positions (e.g.,leftmostn=2, topmostn=2), the positions object will have duplicate keys where later assignments overwrite earlier ones, resulting in fewer than 3 pads being generated.Fix: Add validation after line 122:
Spotted by Graphite

Is this helpful? React 👍 or 👎 to let us know.