From f2075885c3dfa53f11218f26c8754362faa68421 Mon Sep 17 00:00:00 2001 From: Hazim Majed Alsalmi <302658418+ikoomm@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:50:09 +0300 Subject: [PATCH] Add SGM2036 UTDFN-4-EP footprint with diamond thermal pad --- README.md | 20 ++++ src/fn/index.ts | 1 + src/fn/utdfn.ts | 79 ++++++++++++++ src/footprinter.ts | 2 + tests/__snapshots__/utdfn4_no_ep.snap.svg | 1 + tests/__snapshots__/utdfn4_sgm2036.snap.svg | 1 + tests/utdfn.test.ts | 113 ++++++++++++++++++++ 7 files changed, 217 insertions(+) create mode 100644 src/fn/utdfn.ts create mode 100644 tests/__snapshots__/utdfn4_no_ep.snap.svg create mode 100644 tests/__snapshots__/utdfn4_sgm2036.snap.svg create mode 100644 tests/utdfn.test.ts diff --git a/README.md b/README.md index 1c799d2a..3bebe081 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ pinrow6_rows2_cols5_p2.54mm_py5.08mm_missing(3,4,8,9) smdpinheader6 tssop20_p0.5mm sot23 +utdfn4 qfn24_w6_h6_p0.8mm_thermalpad_startingpin(topside,rightpin)_ccw qfn64_thermalpad6.3mmx6.3mm_thermalvias4x4_thermalviapitch1mm_thermalviaid0.3048mm_thermalviaod0.6096mm axial_p0.2in @@ -78,6 +79,25 @@ fp.string("dip4_w7.62mm") // same as fp.dip(4).w(7.62) fp.string("dip4_w0.3in") // same as fp.dip(4).w("0.3in") ``` +### UTDFN-4-EP (1x1mm) + +`utdfn4`, `utdfn`, and `UTDFN-4-EP(1x1)` generate the SGM2036 +UTDFN-1x1-4L land pattern from SGMICRO drawing TX00066.000. The footprint +has four corner pads and a 0.48mm square exposed pad rotated 45 degrees. +Pin 1 is the longer lower-left pad in the manufacturer's top view; the +remaining pads are numbered counter-clockwise. The exposed pad uses the +`thermalpad` port hint. + +```ts +fp.string("UTDFN-4-EP(1x1)").circuitJson() +fp().utdfn(4).circuitJson() +fp().utdfn().ep(false).circuitJson() // explicitly omit the exposed pad +``` + +These dimensions target SGM2036, including the SGM2036-1.2YUDH4G/TR listed +under this catalog package. Other manufacturers' 1x1mm packages can have +different land patterns; check the actual part's datasheet. + ### Pin 1 location Every footprint accepts a `pin1location(side,alignment)` modifier that rotates diff --git a/src/fn/index.ts b/src/fn/index.ts index 08c0735d..f7834552 100644 --- a/src/fn/index.ts +++ b/src/fn/index.ts @@ -22,6 +22,7 @@ export { sot886 } from "./sot886" export { sot23 } from "./sot23" export { sot25 } from "./sot25" export { dfn } from "./dfn" +export { utdfn } from "./utdfn" export { pinrow } from "./pinrow" export { headermodule } from "./headermodule" export { sot563 } from "./sot563" diff --git a/src/fn/utdfn.ts b/src/fn/utdfn.ts new file mode 100644 index 00000000..8030ef11 --- /dev/null +++ b/src/fn/utdfn.ts @@ -0,0 +1,79 @@ +import type { AnyCircuitElement, PcbCourtyardRect } from "circuit-json" +import { z } from "zod" +import { polygonpad } from "../helpers/polygonpad" +import { silkscreenpath } from "../helpers/silkscreenpath" +import { silkscreenRef } from "../helpers/silkscreenRef" +import { base_def } from "../helpers/zod/base_def" + +export const utdfn_def = base_def.extend({ + fn: z.literal("utdfn"), + num_pins: z.literal(4).default(4), + ep: z.boolean().default(true).describe("include the exposed thermal pad"), +}) + +/** + * SGM2036 UTDFN-1x1-4L, catalogued as UTDFN-4-EP(1x1). + * SGMICRO package drawing TX00066.000, recommended land pattern (top view): + * https://www.sg-micro.com/rect/assets/efa85993-263c-41aa-9274-b488f59f85d5/SGM2036.pdf + * Different manufacturers' 1x1mm packages are not necessarily land-compatible. + */ +export const utdfn = (rawParams: z.input) => { + const parameters = utdfn_def.parse(rawParams) + const circuitJson: AnyCircuitElement[] = [] + + // Top view: pin 1 lower left, then counter-clockwise. Pin 1 is longer + // and has a full-width diagonal; the other three have 0.18mm chamfers. + const quadrants = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ] as const + for (const [index, [sx, sy]] of quadrants.entries()) { + const innerY = sy * (index === 0 ? 0.18 : 0.25) + const cut = index === 0 ? 0.25 : 0.18 + const points = [ + { x: sx * 0.2, y: innerY + sy * cut }, + { x: sx * 0.2, y: sy * 0.65 }, + { x: sx * 0.45, y: sy * 0.65 }, + { x: sx * 0.45, y: innerY }, + ] + if (index !== 0) points.push({ x: sx * (0.2 + cut), y: innerY }) + circuitJson.push(polygonpad(index + 1, points)) + } + + if (parameters.ep) { + // 0.48mm is the side length, not the axis-aligned bounding-box width. + const radius = 0.48 / Math.sqrt(2) + circuitJson.push( + polygonpad( + ["thermalpad"], + [ + { x: 0, y: radius }, + { x: radius, y: 0 }, + { x: 0, y: -radius }, + { x: -radius, y: 0 }, + ], + ), + ) + } + + for (const [index, [sx, sy]] of quadrants.entries()) { + const route = [{ x: sx * 0.65, y: sy * 0.64 }] + if (index !== 0) route.push({ x: sx * 0.65, y: sy * 0.8 }) + route.push({ x: sx * 0.49, y: sy * 0.8 }) + circuitJson.push(silkscreenpath(route)) + } + circuitJson.push(silkscreenRef(0, 1.2, 0.3)) + const courtyard: PcbCourtyardRect = { + type: "pcb_courtyard_rect", + pcb_courtyard_rect_id: "", + pcb_component_id: "", + center: { x: 0, y: 0 }, + width: 1.5, + height: 1.8, + layer: "top", + } + circuitJson.push(courtyard) + return { circuitJson, parameters } +} diff --git a/src/footprinter.ts b/src/footprinter.ts index cafa8f6b..3fb09f14 100644 --- a/src/footprinter.ts +++ b/src/footprinter.ts @@ -399,6 +399,7 @@ export type Footprinter = { ) => FootprinterParamsBuilder< "p" | "rowspan" | "pl" | "pw" | "ep" | "epw" | "eph" | "w" | "h" > + utdfn: (num_pins?: number) => FootprinterParamsBuilder<"ep"> vssop: ( num_pins?: number, ) => FootprinterParamsBuilder< @@ -573,6 +574,7 @@ const normalizeDefinition = (def: string): string => { return def .trim() .replace(/^pinheader(?=[\d_]|$)/i, "pinrow") + .replace(/^utdfn-4(?:-ep\(1x1\))?(?=_|$)/i, "utdfn4") .replace(/^d2pak(\d+)(?=_|$)/i, "d2pak_$1") .replace(/^to-252(?:-(\d+))?(?=_|$)/i, (_, pins) => pins ? `to252_${pins}` : "to252", diff --git a/tests/__snapshots__/utdfn4_no_ep.snap.svg b/tests/__snapshots__/utdfn4_no_ep.snap.svg new file mode 100644 index 00000000..304a8796 --- /dev/null +++ b/tests/__snapshots__/utdfn4_no_ep.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/utdfn4_sgm2036.snap.svg b/tests/__snapshots__/utdfn4_sgm2036.snap.svg new file mode 100644 index 00000000..eeb37592 --- /dev/null +++ b/tests/__snapshots__/utdfn4_sgm2036.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/utdfn.test.ts b/tests/utdfn.test.ts new file mode 100644 index 00000000..19c24e37 --- /dev/null +++ b/tests/utdfn.test.ts @@ -0,0 +1,113 @@ +import { expect, test } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { fp } from "../src/footprinter" + +const getPads = (definition = "utdfn4") => + fp + .string(definition) + .circuitJson() + .filter((item) => item.type === "pcb_smtpad") + +test("utdfn4 implements the SGM2036 UTDFN-1x1-4L land pattern", () => { + const pads = getPads() + expect(pads.map((pad) => pad.port_hints)).toEqual([ + ["1"], + ["2"], + ["3"], + ["4"], + ["thermalpad"], + ]) + expect(pads.every((pad) => pad.shape === "polygon")).toBe(true) + const bounds = pads.slice(0, 4).map((pad) => { + if (pad.shape !== "polygon") throw new Error("Expected polygon") + return [ + Math.min(...pad.points.map((p) => p.x)), + Math.max(...pad.points.map((p) => p.x)), + Math.min(...pad.points.map((p) => p.y)), + Math.max(...pad.points.map((p) => p.y)), + ] + }) + const expected = [ + [-0.45, -0.2, -0.65, -0.18], + [0.2, 0.45, -0.65, -0.25], + [0.2, 0.45, 0.25, 0.65], + [-0.45, -0.2, 0.25, 0.65], + ] + for (let i = 0; i < 4; i++) + for (let j = 0; j < 4; j++) { + expect(bounds[i]![j]).toBeCloseTo(expected[i]![j]!, 10) + } + expect( + convertCircuitJsonToPcbSvg(fp.string("utdfn4").circuitJson()), + ).toMatchSvgSnapshot(import.meta.path, "utdfn4_sgm2036") +}) + +test("utdfn4 exposed pad is a 0.48mm square rotated 45 degrees", () => { + const ep = getPads().find((pad) => pad.port_hints?.includes("thermalpad"))! + if (ep.shape !== "polygon") throw new Error("Expected diamond") + expect(ep.points).toHaveLength(4) + for (let i = 0; i < 4; i++) { + const a = ep.points[i]!, + b = ep.points[(i + 1) % 4]! + expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeCloseTo(0.48, 10) + expect(Math.abs(a.x) + Math.abs(a.y)).toBeCloseTo(0.48 / Math.sqrt(2), 10) + expect(a.x === 0 || a.y === 0).toBe(true) + } +}) + +test("utdfn4 corner cuts preserve at least 0.2mm clearance from the exposed pad", () => { + for (const pad of getPads().slice(0, 4)) { + if (pad.shape !== "polygon") throw new Error("Expected polygon") + // Each convex signal pad stays in one quadrant. Its nearest parallel + // supporting line is separated from the diamond by this distance. + const minDiagonal = Math.min( + ...pad.points.map((p) => Math.abs(p.x) + Math.abs(p.y)), + ) + expect( + (minDiagonal - 0.48 / Math.sqrt(2)) / Math.sqrt(2), + ).toBeGreaterThanOrEqual(0.2) + expect( + pad.points.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y)), + ).toBe(true) + } +}) + +test("utdfn4 preserves the distinctive extended pin 1 instead of four identical pads", () => { + const pads = getPads() + expect( + pads.slice(0, 4).map((p) => (p.shape === "polygon" ? p.points.length : 0)), + ).toEqual([4, 5, 5, 5]) +}) + +test("utdfn accepts catalog spelling and optional pin count", () => { + const expected = getPads() + for (const definition of ["utdfn", "UTDFN4", "UTDFN-4", "UTDFN-4-EP(1x1)"]) { + expect(getPads(definition)).toEqual(expected) + } + expect(fp().utdfn().circuitJson()).toEqual(fp.string("utdfn4").circuitJson()) +}) + +test("utdfn rejects other pin counts", () => { + for (const definition of ["utdfn3", "utdfn6"]) { + expect(() => fp.string(definition).circuitJson()).toThrow() + } +}) + +test("utdfn applies shared silkscreen and refdes modifiers", () => { + const result = fp + .string("UTDFN-4-EP(1x1)_nosilkscreen_norefdes") + .circuitJson() + expect( + result.filter((item) => item.type.startsWith("pcb_silkscreen")), + ).toHaveLength(0) + expect(result.filter((item) => item.type === "pcb_smtpad")).toHaveLength(5) +}) + +test("utdfn exposed pad can be omitted explicitly", () => { + const result = fp().utdfn(4).ep(false).circuitJson() + expect(result.filter((item) => item.type === "pcb_smtpad")).toHaveLength(4) + expect(convertCircuitJsonToPcbSvg(result)).toMatchSvgSnapshot( + import.meta.path, + "utdfn4_no_ep", + ) +})