From add353fba1c246aa17190aefde994920597fed37 Mon Sep 17 00:00:00 2001 From: Zhiqiang Jia Date: Thu, 19 Mar 2026 19:22:36 +0700 Subject: [PATCH] feat: implement TO-220 horizontal variant (to220h) Closes #185 Adds to220h footprint for the TO-220 horizontal (lying-down) package: - Signal pins in a horizontal row at 2.54mm pitch - Mounting tab as a plated hole with silkscreen outline - tabup parameter: tab extends toward +Y (default: tabdown, toward -Y) - String aliases: to220h_3, to220h_3_tabup, TO-220-3_Horizontal, TO-220-3_Horizontal_TabUp - 5 tests with SVG snapshot coverage --- src/fn/index.ts | 1 + src/fn/to220h.ts | 146 ++++++++++++++++++++ src/footprinter.ts | 8 ++ tests/__snapshots__/to220h_2.snap.svg | 1 + tests/__snapshots__/to220h_3.snap.svg | 1 + tests/__snapshots__/to220h_3_tabup.snap.svg | 1 + tests/to220h.test.ts | 58 ++++++++ 7 files changed, 216 insertions(+) create mode 100644 src/fn/to220h.ts create mode 100644 tests/__snapshots__/to220h_2.snap.svg create mode 100644 tests/__snapshots__/to220h_3.snap.svg create mode 100644 tests/__snapshots__/to220h_3_tabup.snap.svg create mode 100644 tests/to220h.test.ts diff --git a/src/fn/index.ts b/src/fn/index.ts index 94c0370e..2414ff7c 100644 --- a/src/fn/index.ts +++ b/src/fn/index.ts @@ -81,3 +81,4 @@ export { sot343 } from "./sot343" export { m2host } from "./m2host" export { mountedpcbmodule } from "./mountedpcbmodule" export { to92l } from "./to92l" +export { to220h } from "./to220h" diff --git a/src/fn/to220h.ts b/src/fn/to220h.ts new file mode 100644 index 00000000..81410990 --- /dev/null +++ b/src/fn/to220h.ts @@ -0,0 +1,146 @@ +import { + type AnyCircuitElement, + type PcbSilkscreenPath, + length, +} from "circuit-json" +import { platedhole } from "src/helpers/platedhole" +import { rectpad } from "src/helpers/rectpad" +import { z } from "zod" +import { type SilkscreenRef, silkscreenRef } from "../helpers/silkscreenRef" +import { base_def } from "../helpers/zod/base_def" + +/** + * TO-220 Horizontal variant footprint. + * + * Matches KiCad TO-220-3_Horizontal_TabDown / TO-220-3_Horizontal_TabUp. + * + * Signal pins emerge horizontally in a row; the mounting tab pad extends + * either downward (tabdown, default) or upward (tabup). + * + * Parameters: + * tabup – tab extends toward +Y (default: false → TabDown, tab toward -Y) + * p – pitch between signal pins (default 2.54mm) + * id – signal pin drill diameter (default 1.0mm) + * od – signal pin annular ring outer diameter (default 1.8mm) + * w – body width (default 10.16mm, KiCad standard) + * h – body height (default 4.0mm, KiCad standard) + * tabid – tab mounting hole drill diameter (default 3.2mm) + * tabod – tab mounting hole outer diameter (default 5.4mm) + * tabw – tab SMT pad width (default 6.6mm) + * tabh – tab SMT pad height (default 4.6mm) + */ +export const to220h_def = base_def.extend({ + fn: z.string(), + tabup: z.boolean().optional().default(false), + p: length.optional().default("2.54mm"), + id: length.optional().default("1.0mm"), + od: length.optional().default("1.8mm"), + w: length.optional().default("10.16mm"), + h: length.optional().default("4.0mm"), + tabid: length.optional().default("3.2mm"), + tabod: length.optional().default("5.4mm"), + tabw: length.optional().default("6.6mm"), + tabh: length.optional().default("4.6mm"), + num_pins: z.number().optional(), + string: z.string().optional(), +}) + +export type To220hDef = z.input + +export const to220h = ( + raw_params: To220hDef, +): { circuitJson: AnyCircuitElement[]; parameters: any } => { + const parameters = to220h_def.parse(raw_params) + const { tabup, p, id, od, w, h, tabid, tabod, tabw, tabh, string } = + parameters + + const numPins = + parameters.num_pins ?? + Number.parseInt(string?.match(/^to220h(?:_|-)(\d+)/i)?.[1] ?? "3") + + // Signal pins in a horizontal row at y = 0 + const signalPins: AnyCircuitElement[] = Array.from( + { length: numPins }, + (_, i) => { + const x = + numPins % 2 === 0 + ? (i - numPins / 2 + 0.5) * p + : (i - Math.floor(numPins / 2)) * p + return platedhole(i + 1, x, 0, id, od) + }, + ) + + // Tab pad: large rectangular SMT pad with mounting hole + // TabDown: tab extends below pins (negative Y); TabUp: above pins (positive Y) + const tabSign = tabup ? 1 : -1 + const tabCenterY = tabSign * (h / 2 + tabh / 2 + 0.5) + + // Mounting hole in the tab (through-hole, no electrical connection – pin "tab") + const tabHole: AnyCircuitElement = platedhole( + numPins + 1, + 0, + tabCenterY, + tabid, + tabod, + ) + + // Silkscreen body outline + const halfW = w / 2 + const pinsTop = od / 2 + 0.3 + const pinsBottom = -(od / 2 + 0.3) + + // Draw an outline around the body region (between pins and tab) + const bodyBottom = tabSign > 0 ? pinsBottom : tabCenterY - tabh / 2 - 0.3 + const bodyTop = tabSign > 0 ? tabCenterY + tabh / 2 + 0.3 : pinsTop + + const silkBody: PcbSilkscreenPath = { + type: "pcb_silkscreen_path", + layer: "top", + pcb_component_id: "", + pcb_silkscreen_path_id: "", + route: [ + { x: -halfW, y: pinsTop }, + { x: halfW, y: pinsTop }, + { x: halfW, y: pinsBottom }, + { x: -halfW, y: pinsBottom }, + { x: -halfW, y: pinsTop }, + ], + stroke_width: 0.1, + } + + // Tab outline + const tabLeft = -(tabw / 2) + const tabRight = tabw / 2 + const tabTop = tabCenterY + tabh / 2 + const tabBot = tabCenterY - tabh / 2 + + const silkTab: PcbSilkscreenPath = { + type: "pcb_silkscreen_path", + layer: "top", + pcb_component_id: "", + pcb_silkscreen_path_id: "", + route: [ + { x: tabLeft, y: tabTop }, + { x: tabRight, y: tabTop }, + { x: tabRight, y: tabBot }, + { x: tabLeft, y: tabBot }, + { x: tabLeft, y: tabTop }, + ], + stroke_width: 0.1, + } + + // Reference designator text: place opposite side from tab + const refY = tabSign > 0 ? pinsBottom - 0.8 : pinsTop + 0.8 + const silkscreenRefText: SilkscreenRef = silkscreenRef(0, refY, 0.5) + + return { + circuitJson: [ + ...signalPins, + tabHole, + silkBody, + silkTab, + silkscreenRefText as AnyCircuitElement, + ], + parameters: { ...parameters, p, num_pins: numPins }, + } +} diff --git a/src/footprinter.ts b/src/footprinter.ts index 6af68a44..f634f1ee 100644 --- a/src/footprinter.ts +++ b/src/footprinter.ts @@ -110,6 +110,9 @@ export type Footprinter = { hc49: () => FootprinterParamsBuilder<"p" | "id" | "od" | "w" | "h"> to220: () => FootprinterParamsBuilder<"w" | "h" | "p" | "id" | "od"> to220f: () => FootprinterParamsBuilder<"w" | "h" | "p" | "id" | "od"> + to220h: () => FootprinterParamsBuilder< + "w" | "h" | "p" | "id" | "od" | "tabup" | "tabid" | "tabod" | "tabw" | "tabh" + > sot363: () => FootprinterParamsBuilder<"w" | "h" | "p" | "pl" | "pw"> sot886: () => FootprinterParamsBuilder<"w" | "h" | "p" | "pl" | "pw"> sot457: () => FootprinterParamsBuilder< @@ -271,6 +274,11 @@ const normalizeDefinition = (def: string): string => { .trim() .replace(/^sot-223-(\d+)(?=_|$)/i, "sot223_$1") .replace(/^to-220f-(\d+)(?=_|$)/i, "to220f_$1") + .replace(/^to-220-(\d+)_horizontal(?:_tabup)?(?=_|$)/i, (m, n) => + m.toLowerCase().includes("tabup") + ? `to220h_${n}_tabup` + : `to220h_${n}`, + ) } export const string = (def: string): Footprinter => { diff --git a/tests/__snapshots__/to220h_2.snap.svg b/tests/__snapshots__/to220h_2.snap.svg new file mode 100644 index 00000000..3448a41f --- /dev/null +++ b/tests/__snapshots__/to220h_2.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/to220h_3.snap.svg b/tests/__snapshots__/to220h_3.snap.svg new file mode 100644 index 00000000..18a69cb0 --- /dev/null +++ b/tests/__snapshots__/to220h_3.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/to220h_3_tabup.snap.svg b/tests/__snapshots__/to220h_3_tabup.snap.svg new file mode 100644 index 00000000..8d4dd03e --- /dev/null +++ b/tests/__snapshots__/to220h_3_tabup.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/to220h.test.ts b/tests/to220h.test.ts new file mode 100644 index 00000000..6f7a3325 --- /dev/null +++ b/tests/to220h.test.ts @@ -0,0 +1,58 @@ +import { test, expect } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { fp } from "src/footprinter" + +test("to220h_3 (3 pins, tab down, default)", () => { + const circuitJson = fp.string("to220h_3").circuitJson() + const svgContent = convertCircuitJsonToPcbSvg(circuitJson) + + expect(circuitJson).toBeDefined() + expect(circuitJson.length).toBeGreaterThan(0) + + // Should have 3 signal plated holes + 1 tab hole + const holes = circuitJson.filter((e: any) => e.type === "pcb_plated_hole") + expect(holes).toHaveLength(4) + + expect(svgContent).toMatchSvgSnapshot(import.meta.path, "to220h_3") +}) + +test("to220h_3_tabup (3 pins, tab up)", () => { + const circuitJson = fp.string("to220h_3_tabup").circuitJson() + const svgContent = convertCircuitJsonToPcbSvg(circuitJson) + + expect(circuitJson).toBeDefined() + expect(circuitJson.length).toBeGreaterThan(0) + + expect(svgContent).toMatchSvgSnapshot(import.meta.path, "to220h_3_tabup") +}) + +test("to220h_2 (2 pins, tab down)", () => { + const circuitJson = fp.string("to220h_2").circuitJson() + const svgContent = convertCircuitJsonToPcbSvg(circuitJson) + + expect(circuitJson).toBeDefined() + expect(circuitJson.length).toBeGreaterThan(0) + + expect(svgContent).toMatchSvgSnapshot(import.meta.path, "to220h_2") +}) + +test("TO-220-3_Horizontal (KiCad alias)", () => { + const circuitJson = fp.string("TO-220-3_Horizontal").circuitJson() + + expect(circuitJson).toBeDefined() + expect(circuitJson.length).toBeGreaterThan(0) + + // Must match the canonical to220h_3 output + const canonical = fp.string("to220h_3").circuitJson() + expect(JSON.stringify(circuitJson)).toEqual(JSON.stringify(canonical)) +}) + +test("TO-220-3_Horizontal_TabUp (KiCad alias)", () => { + const circuitJson = fp.string("TO-220-3_Horizontal_TabUp").circuitJson() + + expect(circuitJson).toBeDefined() + expect(circuitJson.length).toBeGreaterThan(0) + + const canonical = fp.string("to220h_3_tabup").circuitJson() + expect(JSON.stringify(circuitJson)).toEqual(JSON.stringify(canonical)) +})