Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions src/fn/customsmd3.ts
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
Comment on lines +104 to +107
Copy link
Copy Markdown
Contributor

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, and bottommostn are 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:

const pinNumbers = [leftPin, topPin, bottomPin]
if (new Set(pinNumbers).size !== 3) {
  throw new Error('leftmostn, topmostn, and bottommostn must specify distinct pin numbers')
}
Suggested change
// --- 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
// --- 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
const pinNumbers = [leftPin, topPin, bottomPin]
if (new Set(pinNumbers).size !== 3) {
throw new Error('leftmostn, topmostn, and bottommostn must specify distinct pin numbers')
}

Spotted by Graphite

Fix in Graphite


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


// 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pad creation loop hardcodes pinNum = 1; pinNum <= 3 but doesn't validate that leftmostn, topmostn, and bottommostn are within this range. If a user sets leftmostn=4, a position would be calculated for pin 4 in the positions object (line 163) but the loop would never create that pad, resulting in only 2 pads being generated instead of 3.

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

Fix in Graphite


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


// --- 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,
}
}
1 change: 1 addition & 0 deletions src/fn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,4 @@ export { sot343 } from "./sot343"
export { m2host } from "./m2host"
export { mountedpcbmodule } from "./mountedpcbmodule"
export { to92l } from "./to92l"
export { customsmd3 } from "./customsmd3"
23 changes: 23 additions & 0 deletions src/footprinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,29 @@ export type Footprinter = {
solderjumper: (
num_pins?: number,
) => FootprinterParamsBuilder<"bridged" | "p" | "pw" | "ph">
customsmd3: () => FootprinterParamsBuilder<
| "w"
| "h"
| "r"
| "sym"
| "eqsz"
| "leftmostn"
| "rightmostn"
| "topmostn"
| "bottommostn"
| "c2cvert_1_2"
| "c2cvert_1_3"
| "c2cvert_2_3"
| "c2chorz_1_2"
| "c2chorz_1_3"
| "c2chorz_2_3"
| "e2evert_1_2"
| "e2evert_1_3"
| "e2evert_2_3"
| "e2ehorz_1_2"
| "e2ehorz_1_3"
| "e2ehorz_2_3"
>

params: () => any
/** @deprecated use circuitJson() instead */
Expand Down
1 change: 1 addition & 0 deletions tests/__snapshots__/customsmd3_c2cvert_2_3_2mm.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/__snapshots__/customsmd3_default.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/__snapshots__/customsmd3_e2ehorz_1_2_3mm.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/__snapshots__/customsmd3_leftmostn2.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/__snapshots__/customsmd3_r0.5.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions tests/__snapshots__/customsmd3_w2_h1.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions tests/customsmd3_1.test.ts
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)
})
16 changes: 16 additions & 0 deletions tests/customsmd3_2.test.ts
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)
}
})
16 changes: 16 additions & 0 deletions tests/customsmd3_3.test.ts
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)
}
})
21 changes: 21 additions & 0 deletions tests/customsmd3_4.test.ts
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)
})
15 changes: 15 additions & 0 deletions tests/customsmd3_5.test.ts
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)
})
Loading
Loading