From 5f5c12eb986408c6ae9e02cad949103a12ee035d Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 13 Jul 2026 15:36:26 -0700 Subject: [PATCH 1/3] Add USB-C mid-mount footprint --- src/fn/index.ts | 1 + src/fn/usbcmidmount.ts | 150 +++++++++++++++++++++++++++++++++++++ src/footprinter.ts | 1 + tests/usbcmidmount.test.ts | 42 +++++++++++ 4 files changed, 194 insertions(+) create mode 100644 src/fn/usbcmidmount.ts create mode 100644 tests/usbcmidmount.test.ts diff --git a/src/fn/index.ts b/src/fn/index.ts index 94c0370e..6c7f2d8d 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 { usbcmidmount } from "./usbcmidmount" diff --git a/src/fn/usbcmidmount.ts b/src/fn/usbcmidmount.ts new file mode 100644 index 00000000..4335c2ef --- /dev/null +++ b/src/fn/usbcmidmount.ts @@ -0,0 +1,150 @@ +import type { + AnyCircuitElement, + PcbCourtyardRect, + PcbHoleCircle, + PcbPlatedHoleOval, +} from "circuit-json" +import { z } from "zod" +import { rectpad } from "../helpers/rectpad" +import { silkscreenpath } from "../helpers/silkscreenpath" +import { type SilkscreenRef, silkscreenRef } from "../helpers/silkscreenRef" +import { base_def } from "../helpers/zod/base_def" + +export const usbcmidmount_def = base_def.extend({ + fn: z.literal("usbcmidmount"), + num_pins: z.literal(16).default(16), + pinstart: z.coerce.number().int().positive().default(1), +}) + +const pillPlatedHole = ({ + pin, + x, + y, + holeWidth, + holeHeight, + outerWidth, + outerHeight, +}: { + pin: number + x: number + y: number + holeWidth: number + holeHeight: number + outerWidth: number + outerHeight: number +}): PcbPlatedHoleOval => ({ + type: "pcb_plated_hole", + shape: "pill", + pcb_plated_hole_id: "", + pcb_component_id: "", + pcb_port_id: "", + x, + y, + hole_width: holeWidth, + hole_height: holeHeight, + outer_width: outerWidth, + outer_height: outerHeight, + ccw_rotation: 0, + layers: ["top", "bottom"], + port_hints: [pin.toString()], +}) + +export const usbcmidmount = ( + rawParams: z.input, +): { circuitJson: AnyCircuitElement[]; parameters: any } => { + const parameters = usbcmidmount_def.parse(rawParams) + const pinStart = parameters.pinstart + + const locatorHoles: PcbHoleCircle[] = [-2.89, 2.89].map((x) => ({ + type: "pcb_hole", + pcb_hole_id: "", + pcb_component_id: "", + hole_shape: "circle", + hole_diameter: 0.7, + x, + y: 1.0549, + })) + const shellTabs = [ + pillPlatedHole({ + pin: pinStart, + x: -4.325, + y: 1.5751, + holeWidth: 0.6, + holeHeight: 1.5, + outerWidth: 1.1, + outerHeight: 2, + }), + pillPlatedHole({ + pin: pinStart + 1, + x: 4.325, + y: 1.5751, + holeWidth: 0.6, + holeHeight: 1.5, + outerWidth: 1.1, + outerHeight: 2, + }), + pillPlatedHole({ + pin: pinStart + 2, + x: -4.325, + y: -2.625, + holeWidth: 0.6, + holeHeight: 1.2, + outerWidth: 1.2, + outerHeight: 1.8, + }), + pillPlatedHole({ + pin: pinStart + 3, + x: 4.325, + y: -2.625, + holeWidth: 0.6, + holeHeight: 1.2, + outerWidth: 1.2, + outerHeight: 1.8, + }), + ] + const signalXs = [ + -3.2, -2.4, -1.75, -1.25, -0.75, -0.25, 0.25, 0.75, 1.25, 1.75, 2.4, 3.2, + ] + const signalWidths = [ + 0.55, 0.55, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.55, 0.55, + ] + const signalPads = signalXs.map((x, index) => + rectpad(pinStart + 4 + index, x, 2.125, signalWidths[index], 1.1), + ) + const silkscreen = [ + silkscreenpath([ + { x: -4.5, y: -1.645 }, + { x: -4.5, y: 0.345 }, + ]), + silkscreenpath([ + { x: 4.5, y: -1.645 }, + { x: 4.5, y: 0.345 }, + ]), + silkscreenpath([ + { x: -4.5, y: -5.225 }, + { x: 4.5, y: -5.225 }, + ]), + ] + const ref: SilkscreenRef = silkscreenRef(0, 3.68, 0.5) + const courtyard: PcbCourtyardRect = { + type: "pcb_courtyard_rect", + pcb_courtyard_rect_id: "", + pcb_component_id: "", + center: { x: 0, y: -1.278 }, + width: 10.35, + height: 8.425, + layer: "top", + } + + return { + circuitJson: [ + ...locatorHoles, + ...shellTabs, + ...signalPads, + ...silkscreen, + ref, + courtyard, + ], + parameters, + } +} diff --git a/src/footprinter.ts b/src/footprinter.ts index fa51a3b4..8d2a293b 100644 --- a/src/footprinter.ts +++ b/src/footprinter.ts @@ -258,6 +258,7 @@ export type Footprinter = { solderjumper: ( num_pins?: number, ) => FootprinterParamsBuilder<"bridged" | "p" | "pw" | "ph"> + usbcmidmount: (num_pins?: number) => FootprinterParamsBuilder<"pinstart"> params: () => any /** @deprecated use circuitJson() instead */ diff --git a/tests/usbcmidmount.test.ts b/tests/usbcmidmount.test.ts new file mode 100644 index 00000000..25d302dc --- /dev/null +++ b/tests/usbcmidmount.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import { fp } from "../src/footprinter" + +test("usbcmidmount16 creates the mid-mount shell and signal pattern", () => { + const circuitJson = fp.string("usbcmidmount16_pinstart13").circuitJson() + const holes = circuitJson.filter((element) => element.type === "pcb_hole") + const shellTabs = circuitJson.filter( + (element) => element.type === "pcb_plated_hole", + ) + const signalPads = circuitJson.filter( + (element) => element.type === "pcb_smtpad", + ) + + expect(holes).toHaveLength(2) + expect(shellTabs).toHaveLength(4) + expect(signalPads).toHaveLength(12) + expect(shellTabs.map((pad) => pad.port_hints)).toEqual([ + ["13"], + ["14"], + ["15"], + ["16"], + ]) + expect(signalPads.map((pad) => pad.port_hints)).toEqual( + Array.from({ length: 12 }, (_, index) => [String(index + 17)]), + ) + expect( + signalPads.map(({ x, y, width, height }) => ({ x, y, width, height })), + ).toEqual([ + { x: -3.2, y: 2.125, width: 0.55, height: 1.1 }, + { x: -2.4, y: 2.125, width: 0.55, height: 1.1 }, + { x: -1.75, y: 2.125, width: 0.3, height: 1.1 }, + { x: -1.25, y: 2.125, width: 0.3, height: 1.1 }, + { x: -0.75, y: 2.125, width: 0.3, height: 1.1 }, + { x: -0.25, y: 2.125, width: 0.3, height: 1.1 }, + { x: 0.25, y: 2.125, width: 0.3, height: 1.1 }, + { x: 0.75, y: 2.125, width: 0.3, height: 1.1 }, + { x: 1.25, y: 2.125, width: 0.3, height: 1.1 }, + { x: 1.75, y: 2.125, width: 0.3, height: 1.1 }, + { x: 2.4, y: 2.125, width: 0.55, height: 1.1 }, + { x: 3.2, y: 2.125, width: 0.55, height: 1.1 }, + ]) +}) From d8ac01691bb875c4f226ac7f62ab1105135fed2f Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 13 Jul 2026 15:39:43 -0700 Subject: [PATCH 2/3] Add SVG snapshot test --- tests/__snapshots__/usbcmidmount16.snap.svg | 1 + tests/usbcmidmount.test.ts | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 tests/__snapshots__/usbcmidmount16.snap.svg diff --git a/tests/__snapshots__/usbcmidmount16.snap.svg b/tests/__snapshots__/usbcmidmount16.snap.svg new file mode 100644 index 00000000..cd350ca4 --- /dev/null +++ b/tests/__snapshots__/usbcmidmount16.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/usbcmidmount.test.ts b/tests/usbcmidmount.test.ts index 25d302dc..46dc33f1 100644 --- a/tests/usbcmidmount.test.ts +++ b/tests/usbcmidmount.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" import { fp } from "../src/footprinter" test("usbcmidmount16 creates the mid-mount shell and signal pattern", () => { @@ -39,4 +40,7 @@ test("usbcmidmount16 creates the mid-mount shell and signal pattern", () => { { x: 2.4, y: 2.125, width: 0.55, height: 1.1 }, { x: 3.2, y: 2.125, width: 0.55, height: 1.1 }, ]) + + const svgContent = convertCircuitJsonToPcbSvg(circuitJson) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, "usbcmidmount16") }) From 0fabced6eae1aefb1e9b17193e5b05549368e59b Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 13 Jul 2026 16:19:00 -0700 Subject: [PATCH 3/3] Generalize USB-C mid-mount footprints --- src/fn/usbcmidmount.ts | 197 +++++++--- src/footprinter.ts | 27 +- tests/__snapshots__/usbcmidmount16.snap.svg | 1 - .../usbcmidmount16_C2765186.snap.svg | 1 + .../usbcmidmount16_C2906289.snap.svg | 1 + .../usbcmidmount16_C2906290.snap.svg | 1 + .../usbcmidmount16_C319148.snap.svg | 1 + .../usbcmidmount16_C709357.snap.svg | 1 + tests/fixtures/usbcmidmount-jlcpcb.ts | 351 ++++++++++++++++++ tests/usbcmidmount.test.ts | 25 +- 10 files changed, 547 insertions(+), 59 deletions(-) delete mode 100644 tests/__snapshots__/usbcmidmount16.snap.svg create mode 100644 tests/__snapshots__/usbcmidmount16_C2765186.snap.svg create mode 100644 tests/__snapshots__/usbcmidmount16_C2906289.snap.svg create mode 100644 tests/__snapshots__/usbcmidmount16_C2906290.snap.svg create mode 100644 tests/__snapshots__/usbcmidmount16_C319148.snap.svg create mode 100644 tests/__snapshots__/usbcmidmount16_C709357.snap.svg create mode 100644 tests/fixtures/usbcmidmount-jlcpcb.ts diff --git a/src/fn/usbcmidmount.ts b/src/fn/usbcmidmount.ts index 4335c2ef..b7ad33b5 100644 --- a/src/fn/usbcmidmount.ts +++ b/src/fn/usbcmidmount.ts @@ -1,8 +1,9 @@ -import type { - AnyCircuitElement, - PcbCourtyardRect, - PcbHoleCircle, - PcbPlatedHoleOval, +import { + length, + type AnyCircuitElement, + type PcbCourtyardRect, + type PcbHoleCircle, + type PcbPlatedHoleOval, } from "circuit-json" import { z } from "zod" import { rectpad } from "../helpers/rectpad" @@ -14,6 +15,34 @@ export const usbcmidmount_def = base_def.extend({ fn: z.literal("usbcmidmount"), num_pins: z.literal(16).default(16), pinstart: z.coerce.number().int().positive().default(1), + split: z.boolean().default(false).describe("emit all 16 contact lands"), + reverse: z + .boolean() + .default(false) + .describe("number contact lands right-to-left"), + noholes: z.boolean().default(false).describe("omit the two locator holes"), + rowy: length.default("2.125mm").describe("contact pad row y position"), + ph: length.default("1.1mm").describe("contact pad height"), + pw: length.default("0.3mm").describe("signal contact pad width"), + powerpw: length.default("0.55mm").describe("merged power pad width"), + powerx: length.default("3.2mm").describe("outer merged power pad x"), + shellx: length.default("4.325mm").describe("shell tab x position"), + topy: length.default("1.575mm").describe("upper shell tab y position"), + bottomy: length + .default("2.625mm") + .describe("lower shell tab negative y magnitude"), + tophw: length.default("0.6mm").describe("upper shell slot width"), + tophh: length.default("1.5mm").describe("upper shell slot height"), + topring: length.default("0.25mm").describe("upper shell annular ring"), + bottomhw: length.default("0.6mm").describe("lower shell slot width"), + bottomhh: length.default("1.2mm").describe("lower shell slot height"), + bottomring: length.default("0.3mm").describe("lower shell annular ring"), + holex: length.default("2.89mm").describe("locator hole x position"), + holey: length.default("1.055mm").describe("locator hole y position"), + holed: length.default("0.7mm").describe("locator hole diameter"), + bodybottom: length + .default("5.225mm") + .describe("connector body negative y extent"), }) const pillPlatedHole = ({ @@ -53,86 +82,146 @@ export const usbcmidmount = ( rawParams: z.input, ): { circuitJson: AnyCircuitElement[]; parameters: any } => { const parameters = usbcmidmount_def.parse(rawParams) - const pinStart = parameters.pinstart + const { + pinstart: pinStart, + split, + reverse, + noholes, + rowy, + ph, + pw, + powerpw, + powerx, + shellx, + topy, + bottomy, + tophw, + tophh, + topring, + bottomhw, + bottomhh, + bottomring, + holex, + holey, + holed, + bodybottom, + } = parameters - const locatorHoles: PcbHoleCircle[] = [-2.89, 2.89].map((x) => ({ - type: "pcb_hole", - pcb_hole_id: "", - pcb_component_id: "", - hole_shape: "circle", - hole_diameter: 0.7, - x, - y: 1.0549, - })) + const locatorHoles: PcbHoleCircle[] = noholes + ? [] + : [-holex, holex].map((x) => ({ + type: "pcb_hole", + pcb_hole_id: "", + pcb_component_id: "", + hole_shape: "circle", + hole_diameter: holed, + x, + y: holey, + })) const shellTabs = [ pillPlatedHole({ pin: pinStart, - x: -4.325, - y: 1.5751, - holeWidth: 0.6, - holeHeight: 1.5, - outerWidth: 1.1, - outerHeight: 2, + x: -shellx, + y: topy, + holeWidth: tophw, + holeHeight: tophh, + outerWidth: tophw + 2 * topring, + outerHeight: tophh + 2 * topring, }), pillPlatedHole({ pin: pinStart + 1, - x: 4.325, - y: 1.5751, - holeWidth: 0.6, - holeHeight: 1.5, - outerWidth: 1.1, - outerHeight: 2, + x: shellx, + y: topy, + holeWidth: tophw, + holeHeight: tophh, + outerWidth: tophw + 2 * topring, + outerHeight: tophh + 2 * topring, }), pillPlatedHole({ pin: pinStart + 2, - x: -4.325, - y: -2.625, - holeWidth: 0.6, - holeHeight: 1.2, - outerWidth: 1.2, - outerHeight: 1.8, + x: -shellx, + y: -bottomy, + holeWidth: bottomhw, + holeHeight: bottomhh, + outerWidth: bottomhw + 2 * bottomring, + outerHeight: bottomhh + 2 * bottomring, }), pillPlatedHole({ pin: pinStart + 3, - x: 4.325, - y: -2.625, - holeWidth: 0.6, - holeHeight: 1.2, - outerWidth: 1.2, - outerHeight: 1.8, + x: shellx, + y: -bottomy, + holeWidth: bottomhw, + holeHeight: bottomhh, + outerWidth: bottomhw + 2 * bottomring, + outerHeight: bottomhh + 2 * bottomring, }), ] - const signalXs = [ - -3.2, -2.4, -1.75, -1.25, -0.75, -0.25, 0.25, 0.75, 1.25, 1.75, 2.4, 3.2, + const innerPowerX = Number((powerx - 0.8).toFixed(12)) + const mergedSignalXs = [ + -powerx, + -innerPowerX, + -1.75, + -1.25, + -0.75, + -0.25, + 0.25, + 0.75, + 1.25, + 1.75, + innerPowerX, + powerx, ] - const signalWidths = [ - 0.55, 0.55, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.55, 0.55, + const splitSignalXs = [ + -3.35, -3.05, -2.55, -2.25, -1.75, -1.25, -0.75, -0.25, 0.25, 0.75, 1.25, + 1.75, 2.25, 2.55, 3.05, 3.35, ] + const leftToRightSignalXs = split ? splitSignalXs : mergedSignalXs + const signalXs = reverse + ? leftToRightSignalXs.slice().reverse() + : leftToRightSignalXs + const signalWidths = split + ? signalXs.map(() => pw) + : signalXs.map((_, index) => + index < 2 || index >= signalXs.length - 2 ? powerpw : pw, + ) const signalPads = signalXs.map((x, index) => - rectpad(pinStart + 4 + index, x, 2.125, signalWidths[index], 1.1), + rectpad(pinStart + 4 + index, x, rowy, signalWidths[index], ph), ) + const silkX = Math.min(shellx - Math.max(tophw, bottomhw) / 2 - 0.2, 4.5) const silkscreen = [ silkscreenpath([ - { x: -4.5, y: -1.645 }, - { x: -4.5, y: 0.345 }, + { x: -silkX, y: -bottomy + bottomhh / 2 + bottomring + 0.2 }, + { x: -silkX, y: topy - tophh / 2 - topring - 0.2 }, ]), silkscreenpath([ - { x: 4.5, y: -1.645 }, - { x: 4.5, y: 0.345 }, + { x: silkX, y: -bottomy + bottomhh / 2 + bottomring + 0.2 }, + { x: silkX, y: topy - tophh / 2 - topring - 0.2 }, ]), silkscreenpath([ - { x: -4.5, y: -5.225 }, - { x: 4.5, y: -5.225 }, + { x: -silkX, y: -bodybottom }, + { x: silkX, y: -bodybottom }, ]), ] - const ref: SilkscreenRef = silkscreenRef(0, 3.68, 0.5) + const topCopper = Math.max(rowy + ph / 2, topy + tophh / 2 + topring) + const bottomCopper = Math.min( + -bodybottom, + -bottomy - bottomhh / 2 - bottomring, + ) + const courtyardLeft = + -shellx - + Math.max(tophw + 2 * topring, bottomhw + 2 * bottomring) / 2 - + 0.25 + const courtyardRight = -courtyardLeft + const courtyardTop = topCopper + 0.25 + const courtyardBottom = bottomCopper - 0.25 + const ref: SilkscreenRef = silkscreenRef(0, courtyardTop + 0.75, 0.5) const courtyard: PcbCourtyardRect = { type: "pcb_courtyard_rect", pcb_courtyard_rect_id: "", pcb_component_id: "", - center: { x: 0, y: -1.278 }, - width: 10.35, - height: 8.425, + center: { x: 0, y: (courtyardTop + courtyardBottom) / 2 }, + width: courtyardRight - courtyardLeft, + height: courtyardTop - courtyardBottom, layer: "top", } diff --git a/src/footprinter.ts b/src/footprinter.ts index 8d2a293b..2163cc69 100644 --- a/src/footprinter.ts +++ b/src/footprinter.ts @@ -258,7 +258,32 @@ export type Footprinter = { solderjumper: ( num_pins?: number, ) => FootprinterParamsBuilder<"bridged" | "p" | "pw" | "ph"> - usbcmidmount: (num_pins?: number) => FootprinterParamsBuilder<"pinstart"> + usbcmidmount: ( + num_pins?: number, + ) => FootprinterParamsBuilder< + | "pinstart" + | "split" + | "reverse" + | "noholes" + | "rowy" + | "ph" + | "pw" + | "powerpw" + | "powerx" + | "shellx" + | "topy" + | "bottomy" + | "tophw" + | "tophh" + | "topring" + | "bottomhw" + | "bottomhh" + | "bottomring" + | "holex" + | "holey" + | "holed" + | "bodybottom" + > params: () => any /** @deprecated use circuitJson() instead */ diff --git a/tests/__snapshots__/usbcmidmount16.snap.svg b/tests/__snapshots__/usbcmidmount16.snap.svg deleted file mode 100644 index cd350ca4..00000000 --- a/tests/__snapshots__/usbcmidmount16.snap.svg +++ /dev/null @@ -1 +0,0 @@ -{REF} \ No newline at end of file diff --git a/tests/__snapshots__/usbcmidmount16_C2765186.snap.svg b/tests/__snapshots__/usbcmidmount16_C2765186.snap.svg new file mode 100644 index 00000000..c8945c05 --- /dev/null +++ b/tests/__snapshots__/usbcmidmount16_C2765186.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/usbcmidmount16_C2906289.snap.svg b/tests/__snapshots__/usbcmidmount16_C2906289.snap.svg new file mode 100644 index 00000000..39d3ccbe --- /dev/null +++ b/tests/__snapshots__/usbcmidmount16_C2906289.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/usbcmidmount16_C2906290.snap.svg b/tests/__snapshots__/usbcmidmount16_C2906290.snap.svg new file mode 100644 index 00000000..8ef8fa24 --- /dev/null +++ b/tests/__snapshots__/usbcmidmount16_C2906290.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/usbcmidmount16_C319148.snap.svg b/tests/__snapshots__/usbcmidmount16_C319148.snap.svg new file mode 100644 index 00000000..e0f263d3 --- /dev/null +++ b/tests/__snapshots__/usbcmidmount16_C319148.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/__snapshots__/usbcmidmount16_C709357.snap.svg b/tests/__snapshots__/usbcmidmount16_C709357.snap.svg new file mode 100644 index 00000000..bedda617 --- /dev/null +++ b/tests/__snapshots__/usbcmidmount16_C709357.snap.svg @@ -0,0 +1 @@ +{REF} \ No newline at end of file diff --git a/tests/fixtures/usbcmidmount-jlcpcb.ts b/tests/fixtures/usbcmidmount-jlcpcb.ts new file mode 100644 index 00000000..28e46e18 --- /dev/null +++ b/tests/fixtures/usbcmidmount-jlcpcb.ts @@ -0,0 +1,351 @@ +import type { AnyCircuitElement } from "circuit-json" + +// Copper dimensions below come from EasyEDA footprints imported with +// `tsci import --jlcpcb ` on 2026-07-13. + +type RectCopper = { + kind: "rect" + x: number + y: number + width: number + height: number +} + +type PillRingCopper = { + kind: "pill-ring" + x: number + y: number + holeWidth: number + holeHeight: number + outerWidth: number + outerHeight: number +} + +export type CopperShape = RectCopper | PillRingCopper + +const rect = ( + x: number, + y: number, + width: number, + height: number, +): RectCopper => ({ kind: "rect", x, y, width, height }) + +const pillRing = ( + x: number, + y: number, + holeWidth: number, + holeHeight: number, + outerWidth: number, + outerHeight: number, +): PillRingCopper => ({ + kind: "pill-ring", + x, + y, + holeWidth, + holeHeight, + outerWidth, + outerHeight, +}) + +const symmetricShell = ({ + x, + topY, + bottomY, + topHoleWidth, + topHoleHeight, + topOuterWidth, + topOuterHeight, + bottomHoleWidth, + bottomHoleHeight, + bottomOuterWidth, + bottomOuterHeight, +}: { + x: number + topY: number + bottomY: number + topHoleWidth: number + topHoleHeight: number + topOuterWidth: number + topOuterHeight: number + bottomHoleWidth: number + bottomHoleHeight: number + bottomOuterWidth: number + bottomOuterHeight: number +}): PillRingCopper[] => [ + pillRing( + -x, + topY, + topHoleWidth, + topHoleHeight, + topOuterWidth, + topOuterHeight, + ), + pillRing(x, topY, topHoleWidth, topHoleHeight, topOuterWidth, topOuterHeight), + pillRing( + -x, + -bottomY, + bottomHoleWidth, + bottomHoleHeight, + bottomOuterWidth, + bottomOuterHeight, + ), + pillRing( + x, + -bottomY, + bottomHoleWidth, + bottomHoleHeight, + bottomOuterWidth, + bottomOuterHeight, + ), +] + +const contactRow = ( + xs: number[], + y: number, + widths: number[], + height: number, +): RectCopper[] => xs.map((x, index) => rect(x, y, widths[index]!, height)) + +const mergedXs = [ + -3.2, -2.4, -1.75, -1.25, -0.75, -0.25, 0.25, 0.75, 1.25, 1.75, 2.4, 3.2, +] +const mergedWidths55 = [ + 0.55, 0.55, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.3, 0.55, 0.55, +] +const splitXs = [ + -3.35, -3.05, -2.55, -2.25, -1.75, -1.25, -0.75, -0.25, 0.25, 0.75, 1.25, + 1.75, 2.25, 2.55, 3.05, 3.35, +] + +export const jlcpcbUsbCMidMountVariants: Array<{ + part: string + manufacturerPartNumber: string + definition: string + targetCopper: CopperShape[] +}> = [ + { + part: "C2765186", + manufacturerPartNumber: "TYPE-C 16PIN 2MD(073)", + definition: "usbcmidmount16_pinstart13", + targetCopper: [ + ...symmetricShell({ + x: 4.324985, + topY: 1.57511755, + bottomY: 2.62502645, + topHoleWidth: 0.5999988, + topHoleHeight: 1.499997, + topOuterWidth: 1.0999978, + topOuterHeight: 1.999996, + bottomHoleWidth: 0.5999988, + bottomHoleHeight: 1.1999976, + bottomOuterWidth: 1.1999976, + bottomOuterHeight: 1.7999964, + }), + ...contactRow(mergedXs, 2.12502755, mergedWidths55, 1.0999978), + ], + }, + { + part: "C2906289", + manufacturerPartNumber: "TYPE-C 16P CB0.8 073", + definition: + "usbcmidmount16_noholes_powerx3.21mm_powerpw0.6mm_rowy2.386mm_ph1mm_shellx5.6mm_topy1.164mm_bottomy2.836mm_tophh1.4mm_topring0.3mm_bottomhh1.8mm_bodybottom4.876mm", + targetCopper: [ + ...symmetricShell({ + x: 5.600065, + topY: 1.1638725, + bottomY: 2.8361195, + topHoleWidth: 0.5999988, + topHoleHeight: 1.3999972, + topOuterWidth: 1.1999976, + topOuterHeight: 1.999996, + bottomHoleWidth: 0.5999988, + bottomHoleHeight: 1.7999964, + bottomOuterWidth: 1.1999976, + bottomOuterHeight: 2.3999952, + }), + ...contactRow( + [ + -3.209925, -2.409825, -1.749679, -1.249807, -0.749681, -0.249809, + 0.250317, 0.750189, 1.250315, 1.750187, 2.410079, 3.209925, + ], + 2.3858665, + [ + 0.5999988, 0.5999988, 0.2999994, 0.2999994, 0.2999994, 0.2999994, + 0.2999994, 0.2999994, 0.2999994, 0.2999994, 0.5999988, 0.5999988, + ], + 0.999998, + ), + ], + }, + { + part: "C2906290", + manufacturerPartNumber: "TYPE-C 16P CB1.6 073", + definition: + "usbcmidmount16_split_reverse_noholes_pinstart25_rowy2.325mm_shellx5.62mm_topy1.175mm_bottomy2.825mm_tophh1.3mm_topring0.2mm_bottomhh1.7mm_bottomring0.2mm_bodybottom4.925mm", + targetCopper: [ + ...symmetricShell({ + x: 5.620004, + topY: 1.17494055, + bottomY: 2.82505145, + topHoleWidth: 0.5999988, + topHoleHeight: 1.2999974, + topOuterWidth: 0.999998, + topOuterHeight: 1.6999966, + bottomHoleWidth: 0.5999988, + bottomHoleHeight: 1.6999966, + bottomOuterWidth: 0.999998, + bottomOuterHeight: 2.0999958, + }), + ...contactRow( + splitXs, + 2.32505255, + splitXs.map(() => 0.2999994), + 1.0999978, + ), + ], + }, + { + part: "C709357", + manufacturerPartNumber: "KH-TYPE-C-16P", + definition: + "usbcmidmount16_split_pinstart1_rowy2.115mm_ph1.3mm_shellx4.32mm_topy1.465mm_bottomy2.715mm_tophh1.6mm_topring0.2mm_bottomhh1.4mm_bottomring0.2mm_holey0.935mm_bodybottom5.38mm", + targetCopper: [ + ...symmetricShell({ + x: 4.320032, + topY: 1.46503395, + bottomY: 2.71504405, + topHoleWidth: 0.5999988, + topHoleHeight: 1.5999968, + topOuterWidth: 0.999998, + topOuterHeight: 1.999996, + bottomHoleWidth: 0.5999988, + bottomHoleHeight: 1.3999972, + bottomOuterWidth: 0.999998, + bottomOuterHeight: 1.7999964, + }), + ...contactRow( + splitXs, + 2.11501995, + splitXs.map(() => 0.2999994), + 1.2999974, + ), + ], + }, + { + part: "C319148", + manufacturerPartNumber: "U262-161N-4BVC11", + definition: + "usbcmidmount16_reverse_pinstart13_rowy2.161mm_ph1.3mm_powerpw0.6mm_shellx4.32mm_topy1.419mm_bottomy2.761mm_tophw0.7mm_tophh1.7mm_topring0.25mm_bottomhw0.7mm_bottomhh1.3mm_bottomring0.25mm_holey0.919mm_bodybottom5.4mm", + targetCopper: [ + ...symmetricShell({ + x: 4.320032, + topY: 1.41881865, + bottomY: 2.76125935, + topHoleWidth: 0.700024, + topHoleHeight: 1.700022, + topOuterWidth: 1.1999976, + topOuterHeight: 2.1999956, + bottomHoleWidth: 0.700024, + bottomHoleHeight: 1.3000228, + bottomOuterWidth: 1.1999976, + bottomOuterHeight: 1.7999964, + }), + ...contactRow( + mergedXs, + 2.16126065, + [ + 0.5999988, 0.5999988, 0.2999994, 0.2999994, 0.2999994, 0.2999994, + 0.2999994, 0.2999994, 0.2999994, 0.2999994, 0.5999988, 0.5999988, + ], + 1.2999974, + ), + ], + }, +] + +export const circuitJsonToCopperShapes = ( + circuitJson: AnyCircuitElement[], +): CopperShape[] => + circuitJson.flatMap((element): CopperShape[] => { + if (element.type === "pcb_smtpad" && element.shape === "rect") { + return [rect(element.x, element.y, element.width, element.height)] + } + if (element.type === "pcb_plated_hole" && element.shape === "pill") { + return [ + pillRing( + element.x, + element.y, + element.hole_width, + element.hole_height, + element.outer_width, + element.outer_height, + ), + ] + } + return [] + }) + +const containsPill = ( + x: number, + y: number, + centerX: number, + centerY: number, + width: number, + height: number, +) => { + const radius = width / 2 + const straightHalfHeight = Math.max((height - width) / 2, 0) + const dx = Math.abs(x - centerX) + const dy = Math.max(Math.abs(y - centerY) - straightHalfHeight, 0) + return dx * dx + dy * dy <= radius * radius +} + +const containsCopper = (shape: CopperShape, x: number, y: number) => { + if (shape.kind === "rect") { + return ( + Math.abs(x - shape.x) <= shape.width / 2 && + Math.abs(y - shape.y) <= shape.height / 2 + ) + } + return ( + containsPill(x, y, shape.x, shape.y, shape.outerWidth, shape.outerHeight) && + !containsPill(x, y, shape.x, shape.y, shape.holeWidth, shape.holeHeight) + ) +} + +const shapeBounds = (shape: CopperShape) => { + const width = shape.kind === "rect" ? shape.width : shape.outerWidth + const height = shape.kind === "rect" ? shape.height : shape.outerHeight + return { + left: shape.x - width / 2, + right: shape.x + width / 2, + bottom: shape.y - height / 2, + top: shape.y + height / 2, + } +} + +export const calculateCopperIou = ( + actual: CopperShape[], + target: CopperShape[], + resolution = 0.01, +) => { + const allShapes = [...actual, ...target] + const bounds = allShapes.map(shapeBounds) + const left = Math.min(...bounds.map((bound) => bound.left)) + const right = Math.max(...bounds.map((bound) => bound.right)) + const bottom = Math.min(...bounds.map((bound) => bound.bottom)) + const top = Math.max(...bounds.map((bound) => bound.top)) + let intersectionSamples = 0 + let unionSamples = 0 + + for (let y = bottom + resolution / 2; y < top; y += resolution) { + for (let x = left + resolution / 2; x < right; x += resolution) { + const inActual = actual.some((shape) => containsCopper(shape, x, y)) + const inTarget = target.some((shape) => containsCopper(shape, x, y)) + if (inActual || inTarget) unionSamples += 1 + if (inActual && inTarget) intersectionSamples += 1 + } + } + + return intersectionSamples / unionSamples +} diff --git a/tests/usbcmidmount.test.ts b/tests/usbcmidmount.test.ts index 46dc33f1..fa187476 100644 --- a/tests/usbcmidmount.test.ts +++ b/tests/usbcmidmount.test.ts @@ -1,6 +1,11 @@ import { expect, test } from "bun:test" import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" import { fp } from "../src/footprinter" +import { + calculateCopperIou, + circuitJsonToCopperShapes, + jlcpcbUsbCMidMountVariants, +} from "./fixtures/usbcmidmount-jlcpcb" test("usbcmidmount16 creates the mid-mount shell and signal pattern", () => { const circuitJson = fp.string("usbcmidmount16_pinstart13").circuitJson() @@ -40,7 +45,21 @@ test("usbcmidmount16 creates the mid-mount shell and signal pattern", () => { { x: 2.4, y: 2.125, width: 0.55, height: 1.1 }, { x: 3.2, y: 2.125, width: 0.55, height: 1.1 }, ]) - - const svgContent = convertCircuitJsonToPcbSvg(circuitJson) - expect(svgContent).toMatchSvgSnapshot(import.meta.path, "usbcmidmount16") }) + +for (const variant of jlcpcbUsbCMidMountVariants) { + test(`${variant.part} ${variant.manufacturerPartNumber} copper IoU`, () => { + const circuitJson = fp.string(variant.definition).circuitJson() + const copperIou = calculateCopperIou( + circuitJsonToCopperShapes(circuitJson), + variant.targetCopper, + ) + + console.log(`${variant.part} copper IoU: ${(copperIou * 100).toFixed(2)}%`) + expect(copperIou).toBeGreaterThan(0.98) + expect(convertCircuitJsonToPcbSvg(circuitJson)).toMatchSvgSnapshot( + import.meta.path, + `usbcmidmount16_${variant.part}`, + ) + }) +}