From a478b8e83f8c30930d7d54d0ef8ccb4d63e7f6de Mon Sep 17 00:00:00 2001 From: Aditya Singh <53948039+singhaditya21@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:05:43 +0530 Subject: [PATCH] feat: add PDIP, SPDIP, and UTDFN footprints - pdip: Plastic DIP alias to the standard DIP generator (fixes #371) - spdip: Shrink Plastic DIP with 70 mil (1.778 mm) pitch (fixes #180) - utdfn: Ultra-Thin DFN with exposed pad, defaults to 4-EP(1x1) (fixes #183) Co-Authored-By: Claude Sonnet 4.6 --- src/fn/index.ts | 3 ++ src/fn/pdip.ts | 24 +++++++++ src/fn/spdip.ts | 115 +++++++++++++++++++++++++++++++++++++++++ src/fn/utdfn.ts | 121 ++++++++++++++++++++++++++++++++++++++++++++ tests/pdip.test.ts | 18 +++++++ tests/spdip.test.ts | 18 +++++++ tests/utdfn.test.ts | 14 +++++ 7 files changed, 313 insertions(+) create mode 100644 src/fn/pdip.ts create mode 100644 src/fn/spdip.ts create mode 100644 src/fn/utdfn.ts create mode 100644 tests/pdip.test.ts create mode 100644 tests/spdip.test.ts create mode 100644 tests/utdfn.test.ts diff --git a/src/fn/index.ts b/src/fn/index.ts index 94c0370e..efba0682 100644 --- a/src/fn/index.ts +++ b/src/fn/index.ts @@ -81,3 +81,6 @@ export { sot343 } from "./sot343" export { m2host } from "./m2host" export { mountedpcbmodule } from "./mountedpcbmodule" export { to92l } from "./to92l" +export { pdip } from "./pdip" +export { spdip } from "./spdip" +export { utdfn } from "./utdfn" diff --git a/src/fn/pdip.ts b/src/fn/pdip.ts new file mode 100644 index 00000000..01862a04 --- /dev/null +++ b/src/fn/pdip.ts @@ -0,0 +1,24 @@ +import type { AnyCircuitElement } from "circuit-json" +import { dip, dip_def } from "./dip" +import { z } from "zod" + +export const pdip_def = dip_def + +/** + * PDIP (Plastic Dual In-line Package) — identical layout to DIP. + * Aliases to the standard DIP footprint generator. + */ +export const pdip = ( + raw_params: z.input & { pdip?: boolean }, +): { circuitJson: AnyCircuitElement[]; parameters: any } => { + const match = (raw_params as any).string?.match(/^pdip_?(\d+)/) + const numPins = match + ? Number.parseInt(match[1]!, 10) + : ((raw_params as any).num_pins ?? 8) + + return dip({ + dip: true, + ...raw_params, + num_pins: numPins, + } as any) +} diff --git a/src/fn/spdip.ts b/src/fn/spdip.ts new file mode 100644 index 00000000..f3b55cee --- /dev/null +++ b/src/fn/spdip.ts @@ -0,0 +1,115 @@ +import type { + AnyCircuitElement, + PcbCourtyardRect, + PcbSilkscreenPath, +} from "circuit-json" +import { type SilkscreenRef, silkscreenRef } from "src/helpers/silkscreenRef" +import { base_def } from "../helpers/zod/base_def" +import { z } from "zod" +import { platedhole } from "../helpers/platedhole" +import { platedHoleWithRectPad } from "../helpers/platedHoleWithRectPad" +import { u_curve } from "../helpers/u-curve" +import { getCcwDipCoords } from "./dip" + +/** + * SPDIP — Shrink Plastic DIP. + * Uses 70 mil (1.778 mm) pitch instead of standard 100 mil (2.54 mm). + * Row spacing: 300 mil (7.62 mm). + */ +export const spdip_def = base_def.extend({ + fn: z.string(), + num_pins: z.number().optional().default(28), + w: z.number().optional().default(7.62), + p: z.number().optional().default(1.778), + id: z.number().optional().default(0.6), + od: z.number().optional().default(1.1), +}) + +export const spdip = ( + raw_params: z.input & { spdip?: boolean }, +): { circuitJson: AnyCircuitElement[]; parameters: any } => { + const match = (raw_params as any).string?.match(/^spdip_?(\d+)/) + const numPins = match + ? Number.parseInt(match[1]!, 10) + : ((raw_params as any).num_pins ?? 28) + + const parameters = spdip_def.parse({ ...raw_params, num_pins: numPins }) + + const { w, p, id, od } = parameters as { + w: number + p: number + id: number + od: number + num_pins: number + } + const num_pins = parameters.num_pins! + + const platedHoles: AnyCircuitElement[] = [] + for (let i = 0; i < num_pins; i++) { + const { x, y } = getCcwDipCoords(num_pins, i + 1, w, p, false) + if (i === 0) { + platedHoles.push( + platedHoleWithRectPad({ + pn: i + 1, + x, + y, + holeDiameter: id, + rectPadWidth: od, + rectPadHeight: od, + }), + ) + continue + } + platedHoles.push(platedhole(i + 1, x, y, id, od)) + } + + const padEdgeHeight = (num_pins / 2 - 1) * p + od + const innerGap = w - od + const sw = innerGap - 1 + const sh = (num_pins / 2 - 1) * p + od + 0.4 + + const silkscreenBorder: PcbSilkscreenPath = { + layer: "top", + pcb_component_id: "", + pcb_silkscreen_path_id: "silkscreen_path_1", + route: [ + { x: -sw / 2, y: -sh / 2 }, + { x: -sw / 2, y: sh / 2 }, + ...u_curve.map(({ x, y }) => ({ + x: (x * sw) / 6, + y: (y * sw) / 6 + sh / 2, + })), + { x: sw / 2, y: sh / 2 }, + { x: sw / 2, y: -sh / 2 }, + { x: -sw / 2, y: -sh / 2 }, + ], + type: "pcb_silkscreen_path", + stroke_width: 0.1, + } + + const silkscreenRefText: SilkscreenRef = silkscreenRef(0, sh / 2 + 0.5, 0.4) + + const pinRowSpanX = w + od + const pinRowSpanY = padEdgeHeight + const courtyardHalfWidth = pinRowSpanX / 2 + 0.25 + const courtyardHalfHeight = pinRowSpanY / 2 + 0.72 + const courtyard: PcbCourtyardRect = { + type: "pcb_courtyard_rect", + pcb_courtyard_rect_id: "", + pcb_component_id: "", + center: { x: 0, y: 0 }, + width: courtyardHalfWidth * 2, + height: courtyardHalfHeight * 2, + layer: "top", + } + + return { + circuitJson: [ + ...platedHoles, + silkscreenBorder, + silkscreenRefText, + courtyard, + ], + parameters, + } +} diff --git a/src/fn/utdfn.ts b/src/fn/utdfn.ts new file mode 100644 index 00000000..0fd590af --- /dev/null +++ b/src/fn/utdfn.ts @@ -0,0 +1,121 @@ +import type { + AnyCircuitElement, + PcbCourtyardRect, + PcbSilkscreenPath, +} from "circuit-json" +import { type SilkscreenRef, silkscreenRef } from "src/helpers/silkscreenRef" +import { z } from "zod" +import { rectpad } from "../helpers/rectpad" +import { base_def } from "../helpers/zod/base_def" +import { CORNERS } from "src/helpers/corner" + +/** + * UTDFN — Ultra-Thin Dual Flat No-lead with Exposed Pad. + * + * Default dimensions match UTDFN-4-EP(1x1): + * body 1×1 mm, 4 signal pins (pitch 0.5 mm), 1 exposed pad 0.48×0.48 mm. + */ +export const utdfn_def = base_def.extend({ + fn: z.string(), + num_pins: z.number().optional().default(4), + w: z.number().optional().default(1.0), + h: z.number().optional().default(1.0), + p: z.number().optional().default(0.5), + pl: z.number().optional().default(0.3), + pw: z.number().optional().default(0.25), + epw: z.number().optional().default(0.48), + eph: z.number().optional().default(0.48), +}) + +const getUtdfnPadCoord = ( + num_pins: number, + pn: number, + w: number, + p: number, +): { x: number; y: number } => { + const halfCount = num_pins / 2 + const offset = ((halfCount - 1) / 2) * p + + if (pn <= halfCount) { + return { x: -w / 2, y: offset - (pn - 1) * p } + } + return { x: w / 2, y: -offset + (pn - halfCount - 1) * p } +} + +export const utdfn = ( + raw_params: z.input & { utdfn?: boolean }, +): { circuitJson: AnyCircuitElement[]; parameters: any } => { + const match = (raw_params as any).string?.match(/^utdfn_?(\d+)/) + const numPins = match + ? Number.parseInt(match[1]!, 10) + : ((raw_params as any).num_pins ?? 4) + + const parameters = utdfn_def.parse({ ...raw_params, num_pins: numPins }) + + const { w, h, p, pl, pw, epw, eph } = parameters as { + w: number + h: number + p: number + pl: number + pw: number + epw: number + eph: number + num_pins: number + } + const num_pins = parameters.num_pins! + + const pads: AnyCircuitElement[] = [] + let padOuterHalfWidth = 0 + let padOuterHalfHeight = 0 + + for (let i = 1; i <= num_pins; i++) { + const { x, y } = getUtdfnPadCoord(num_pins, i, w, p) + padOuterHalfWidth = Math.max(padOuterHalfWidth, Math.abs(x) + pl / 2) + padOuterHalfHeight = Math.max(padOuterHalfHeight, Math.abs(y) + pw / 2) + pads.push(rectpad(i, x, y, pl, pw)) + } + + // Exposed pad centred on the component + pads.push(rectpad(num_pins + 1, 0, 0, epw, eph)) + + // Corner-only silkscreen (like DFN style) + const m = Math.min(0.3, p / 2) + const sw = w + m + const sh = h + m + const silkscreenPaths: PcbSilkscreenPath[] = [] + + for (const corner of CORNERS) { + const { dx, dy } = corner + silkscreenPaths.push({ + layer: "top", + pcb_component_id: "", + pcb_silkscreen_path_id: "", + route: [ + { x: (dx * sw) / 2 - dx * p * 0.6, y: (dy * sh) / 2 }, + { x: (dx * sw) / 2, y: (dy * sh) / 2 }, + { x: (dx * sw) / 2, y: (dy * sh) / 2 - dy * p * 0.6 }, + ], + type: "pcb_silkscreen_path", + stroke_width: 0.1, + }) + } + + const silkscreenRefText: SilkscreenRef = silkscreenRef(0, sh / 2 + 0.3, 0.3) + + const courtyardHalfWidth = Math.max(padOuterHalfWidth + 0.15, w / 2 + 0.15) + const courtyardHalfHeight = Math.max(padOuterHalfHeight + 0.15, h / 2 + 0.15) + const courtyard: PcbCourtyardRect = { + type: "pcb_courtyard_rect", + pcb_courtyard_rect_id: "", + pcb_component_id: "", + center: { x: 0, y: 0 }, + width: courtyardHalfWidth * 2, + height: courtyardHalfHeight * 2, + layer: "top", + } + + return { + circuitJson: [...pads, ...silkscreenPaths, silkscreenRefText, courtyard], + parameters, + } +} diff --git a/tests/pdip.test.ts b/tests/pdip.test.ts new file mode 100644 index 00000000..2c6d1776 --- /dev/null +++ b/tests/pdip.test.ts @@ -0,0 +1,18 @@ +import { test, expect } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { fp } from "../src/footprinter" +import type { AnyCircuitElement } from "circuit-json" + +test("pdip8", () => { + const circuitJson = fp.string("pdip8").circuitJson() as AnyCircuitElement[] + expect(circuitJson.length).toBeGreaterThan(0) + const svgContent = convertCircuitJsonToPcbSvg(circuitJson) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, "pdip8") +}) + +test("pdip14", () => { + const circuitJson = fp.string("pdip14").circuitJson() as AnyCircuitElement[] + expect(circuitJson.length).toBeGreaterThan(0) + const svgContent = convertCircuitJsonToPcbSvg(circuitJson) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, "pdip14") +}) diff --git a/tests/spdip.test.ts b/tests/spdip.test.ts new file mode 100644 index 00000000..39f84230 --- /dev/null +++ b/tests/spdip.test.ts @@ -0,0 +1,18 @@ +import { test, expect } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { fp } from "../src/footprinter" +import type { AnyCircuitElement } from "circuit-json" + +test("spdip28", () => { + const circuitJson = fp.string("spdip28").circuitJson() as AnyCircuitElement[] + expect(circuitJson.length).toBeGreaterThan(0) + const svgContent = convertCircuitJsonToPcbSvg(circuitJson) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, "spdip28") +}) + +test("spdip16", () => { + const circuitJson = fp.string("spdip16").circuitJson() as AnyCircuitElement[] + expect(circuitJson.length).toBeGreaterThan(0) + const svgContent = convertCircuitJsonToPcbSvg(circuitJson) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, "spdip16") +}) diff --git a/tests/utdfn.test.ts b/tests/utdfn.test.ts new file mode 100644 index 00000000..04de667e --- /dev/null +++ b/tests/utdfn.test.ts @@ -0,0 +1,14 @@ +import { test, expect } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { fp } from "../src/footprinter" +import type { AnyCircuitElement } from "circuit-json" + +test("utdfn4 (UTDFN-4-EP 1x1)", () => { + const circuitJson = fp.string("utdfn4").circuitJson() as AnyCircuitElement[] + // 4 signal pads + 1 exposed pad + silkscreen paths + ref + courtyard + expect(circuitJson.length).toBeGreaterThan(0) + const smtpads = circuitJson.filter((e) => e.type === "pcb_smtpad") + expect(smtpads.length).toBe(5) // 4 pins + EP + const svgContent = convertCircuitJsonToPcbSvg(circuitJson) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, "utdfn4") +})