From cce9f52844f859f40e4d0c54439713489b4f9dad Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Tue, 29 Jul 2025 14:07:16 -0700 Subject: [PATCH] feat(pinrow): add startingpin support and tests --- src/fn/pinrow.ts | 54 ++++++++++--- src/helpers/get-pinrow-start-index.ts | 57 ++++++++++++++ ...2_startingpin(bottomside,leftpin).snap.svg | 1 + ..._startingpin(bottomside,rightpin).snap.svg | 1 + ...ws2_startingpin(rightside,toppin).snap.svg | 1 + ...ws2_startingpin(topside,rightpin).snap.svg | 1 + tests/pinrow.test.ts | 77 +++++++++++++++++++ 7 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 src/helpers/get-pinrow-start-index.ts create mode 100644 tests/__snapshots__/pinrow12_rows2_startingpin(bottomside,leftpin).snap.svg create mode 100644 tests/__snapshots__/pinrow12_rows2_startingpin(bottomside,rightpin).snap.svg create mode 100644 tests/__snapshots__/pinrow12_rows2_startingpin(rightside,toppin).snap.svg create mode 100644 tests/__snapshots__/pinrow12_rows2_startingpin(topside,rightpin).snap.svg diff --git a/src/fn/pinrow.ts b/src/fn/pinrow.ts index 9931419e..89ecbde4 100644 --- a/src/fn/pinrow.ts +++ b/src/fn/pinrow.ts @@ -6,6 +6,8 @@ import { silkscreenRef, type SilkscreenRef } from "src/helpers/silkscreenRef" import { silkscreenPin } from "src/helpers/silkscreenPin" import { mm } from "@tscircuit/mm" import { determinePinlabelAnchorSide } from "src/helpers/determine-pin-label-anchor-side" +import { pin_order_specifier } from "src/helpers/zod/pin-order-specifier" +import { getPinrowStartIndex } from "src/helpers/get-pinrow-start-index" export const pinrow_def = z .object({ @@ -22,6 +24,12 @@ export const pinrow_def = z od: length.default("1.5mm").describe("outer diameter"), male: z.boolean().optional().describe("for male pin headers"), female: z.boolean().optional().describe("for female pin headers"), + startingpin: z + .string() + .or(z.array(pin_order_specifier)) + .transform((v) => (typeof v === "string" ? v.slice(1, -1).split(",") : v)) + .pipe(z.array(pin_order_specifier)) + .optional(), pinlabeltextalignleft: z.boolean().optional().default(false), pinlabeltextaligncenter: z.boolean().optional().default(false), pinlabeltextalignright: z.boolean().optional().default(false), @@ -80,6 +88,7 @@ export const pinrow = ( od, rows, num_pins, + startingpin, pinlabelAnchorSide, pinlabelverticallyinverted, pinlabelorthogonal, @@ -97,6 +106,9 @@ export const pinrow = ( const numPinsPerRow = Math.ceil(num_pins / rows) const ySpacing = -p + const positions: Array<{ row: number; col: number; x: number; y: number }> = + [] + const calculateAnchorPosition = ({ xoff, yoff, @@ -198,28 +210,30 @@ export const pinrow = ( const useBGAStyle = rows > 2 && numPinsPerRow > 2 if (rows === 1) { - // Single row: left to right, pin 1 to num_pins + // Single row: left to right const xStart = -((num_pins - 1) / 2) * p for (let i = 0; i < num_pins; i++) { - const pinNumber = i + 1 const xoff = xStart + i * p const posKey = `${xoff},${0}` if (usedPositions.has(posKey)) throw new Error(`Overlap at ${posKey}`) usedPositions.add(posKey) - addPin(pinNumber, xoff, 0) + positions.push({ row: 0, col: i, x: xoff, y: 0 }) } } else if (useBGAStyle) { // BGA-style: row-major numbering (left to right, top to bottom) const xStart = -((numPinsPerRow - 1) / 2) * p - let currentPin = 1 - for (let row = 0; row < rows && currentPin <= num_pins; row++) { - for (let col = 0; col < numPinsPerRow && currentPin <= num_pins; col++) { + for (let row = 0; row < rows && positions.length < num_pins; row++) { + for ( + let col = 0; + col < numPinsPerRow && positions.length < num_pins; + col++ + ) { const xoff = xStart + col * p const yoff = row * ySpacing const posKey = `${xoff},${yoff}` if (usedPositions.has(posKey)) throw new Error(`Overlap at ${posKey}`) usedPositions.add(posKey) - addPin(currentPin++, xoff, yoff) + positions.push({ row, col, x: xoff, y: yoff }) } } } else { @@ -239,7 +253,8 @@ export const pinrow = ( const posKey = `${xoff},${yoff}` if (usedPositions.has(posKey)) throw new Error(`Overlap at ${posKey}`) usedPositions.add(posKey) - addPin(currentPin++, xoff, yoff) + positions.push({ row, col: left, x: xoff, y: yoff }) + currentPin++ } left++ @@ -250,7 +265,8 @@ export const pinrow = ( const posKey = `${xoff},${yoff}` if (usedPositions.has(posKey)) throw new Error(`Overlap at ${posKey}`) usedPositions.add(posKey) - addPin(currentPin++, xoff, yoff) + positions.push({ row: bottom, col, x: xoff, y: yoff }) + currentPin++ } bottom-- @@ -262,7 +278,8 @@ export const pinrow = ( const posKey = `${xoff},${yoff}` if (usedPositions.has(posKey)) throw new Error(`Overlap at ${posKey}`) usedPositions.add(posKey) - addPin(currentPin++, xoff, yoff) + positions.push({ row, col: right, x: xoff, y: yoff }) + currentPin++ } right-- } @@ -275,13 +292,15 @@ export const pinrow = ( const posKey = `${xoff},${yoff}` if (usedPositions.has(posKey)) throw new Error(`Overlap at ${posKey}`) usedPositions.add(posKey) - addPin(currentPin++, xoff, yoff) + positions.push({ row: top, col, x: xoff, y: yoff }) + currentPin++ } top++ } } // Verify all pins were assigned + if (currentPin - 1 < num_pins) { throw new Error( `Missing pins: assigned ${currentPin - 1}, expected ${num_pins}`, @@ -289,6 +308,19 @@ export const pinrow = ( } } + const startIndex = getPinrowStartIndex({ + positions: positions.map((p) => ({ row: p.row, col: p.col })), + rows, + numPinsPerRow, + startingpin, + }) + + for (let i = 0; i < positions.length; i++) { + const pinNumber = ((i - startIndex + num_pins) % num_pins) + 1 + const { x, y } = positions[i] + addPin(pinNumber, x, y) + } + // Add centered silkscreen reference text const refText: SilkscreenRef = silkscreenRef(0, p, 0.5) diff --git a/src/helpers/get-pinrow-start-index.ts b/src/helpers/get-pinrow-start-index.ts new file mode 100644 index 00000000..01dd3821 --- /dev/null +++ b/src/helpers/get-pinrow-start-index.ts @@ -0,0 +1,57 @@ +import type { PinOrderSpecifier } from "./zod/pin-order-specifier" + +export function getPinrowStartIndex({ + positions, + rows, + numPinsPerRow, + startingpin, +}: { + positions: Array<{ row: number; col: number }> + rows: number + numPinsPerRow: number + startingpin?: PinOrderSpecifier[] +}): number { + const sfp: Record = {} as any + for (const specifier of startingpin ?? []) { + sfp[specifier] = true + } + + if (!sfp.leftside && !sfp.topside && !sfp.rightside && !sfp.bottomside) { + sfp.leftside = true + } + + if (!sfp.bottompin && !sfp.leftpin && !sfp.rightpin && !sfp.toppin) { + if (sfp.leftside) { + sfp.toppin = true + } else if (sfp.topside) { + sfp.rightpin = true + } else if (sfp.rightside) { + sfp.bottompin = true + } else if (sfp.bottomside) { + sfp.leftpin = true + } + } + + let targetRow: number | undefined + let targetCol: number | undefined + + if (sfp.toppin) targetRow = 0 + else if (sfp.bottompin) targetRow = rows - 1 + + if (sfp.leftpin) targetCol = 0 + else if (sfp.rightpin) targetCol = numPinsPerRow - 1 + + if (targetRow === undefined) { + if (sfp.topside) targetRow = 0 + else if (sfp.bottomside) targetRow = rows - 1 + } + if (targetCol === undefined) { + if (sfp.leftside) targetCol = 0 + else if (sfp.rightside) targetCol = numPinsPerRow - 1 + } + + const idx = positions.findIndex( + (p) => p.row === targetRow && p.col === targetCol, + ) + return idx === -1 ? 0 : idx +} diff --git a/tests/__snapshots__/pinrow12_rows2_startingpin(bottomside,leftpin).snap.svg b/tests/__snapshots__/pinrow12_rows2_startingpin(bottomside,leftpin).snap.svg new file mode 100644 index 00000000..43378547 --- /dev/null +++ b/tests/__snapshots__/pinrow12_rows2_startingpin(bottomside,leftpin).snap.svg @@ -0,0 +1 @@ +{PIN12}{PIN1}{PIN2}{PIN3}{PIN4}{PIN5}{PIN6}{PIN7}{PIN8}{PIN9}{PIN10}{PIN11}{REF} \ No newline at end of file diff --git a/tests/__snapshots__/pinrow12_rows2_startingpin(bottomside,rightpin).snap.svg b/tests/__snapshots__/pinrow12_rows2_startingpin(bottomside,rightpin).snap.svg new file mode 100644 index 00000000..299cc446 --- /dev/null +++ b/tests/__snapshots__/pinrow12_rows2_startingpin(bottomside,rightpin).snap.svg @@ -0,0 +1 @@ +{PIN7}{PIN8}{PIN9}{PIN10}{PIN11}{PIN12}{PIN1}{PIN2}{PIN3}{PIN4}{PIN5}{PIN6}{REF} \ No newline at end of file diff --git a/tests/__snapshots__/pinrow12_rows2_startingpin(rightside,toppin).snap.svg b/tests/__snapshots__/pinrow12_rows2_startingpin(rightside,toppin).snap.svg new file mode 100644 index 00000000..8f226747 --- /dev/null +++ b/tests/__snapshots__/pinrow12_rows2_startingpin(rightside,toppin).snap.svg @@ -0,0 +1 @@ +{PIN6}{PIN7}{PIN8}{PIN9}{PIN10}{PIN11}{PIN12}{PIN1}{PIN2}{PIN3}{PIN4}{PIN5}{REF} \ No newline at end of file diff --git a/tests/__snapshots__/pinrow12_rows2_startingpin(topside,rightpin).snap.svg b/tests/__snapshots__/pinrow12_rows2_startingpin(topside,rightpin).snap.svg new file mode 100644 index 00000000..8f226747 --- /dev/null +++ b/tests/__snapshots__/pinrow12_rows2_startingpin(topside,rightpin).snap.svg @@ -0,0 +1 @@ +{PIN6}{PIN7}{PIN8}{PIN9}{PIN10}{PIN11}{PIN12}{PIN1}{PIN2}{PIN3}{PIN4}{PIN5}{REF} \ No newline at end of file diff --git a/tests/pinrow.test.ts b/tests/pinrow.test.ts index 75fac0f5..ea8cdc74 100644 --- a/tests/pinrow.test.ts +++ b/tests/pinrow.test.ts @@ -1,6 +1,7 @@ import { test, expect } from "bun:test" import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" import { fp } from "../src/footprinter" +import type { PcbPlatedHole } from "circuit-json" test("pinrow5", () => { const soup = fp.string("pinrow5").circuitJson() @@ -204,3 +205,79 @@ test("pinrow5_bottomsidepinlabel", () => { "pinrow5_bottomsidepinlabel", ) }) + +test("pinrow12_rows2_startingpin(bottomside,leftpin)", () => { + const def = "pinrow12_rows2_startingpin(bottomside,leftpin)" + const soup = fp.string(def).circuitJson() + const svgContent = convertCircuitJsonToPcbSvg(soup) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, def) + + const pads = soup.filter( + (el): el is PcbPlatedHole => + el.type === "pcb_plated_hole" && el.port_hints?.[0] !== undefined, + ) + const pin1 = pads.find((p) => p.port_hints![0] === "1")! + const xs = pads.map((p) => p.x) + const ys = pads.map((p) => p.y) + const minX = Math.min(...xs) + const minY = Math.min(...ys) + expect(pin1.x).toBe(minX) + expect(pin1.y).toBe(minY) +}) + +test("pinrow12_rows2_startingpin(bottomside,rightpin)", () => { + const def = "pinrow12_rows2_startingpin(bottomside,rightpin)" + const soup = fp.string(def).circuitJson() + const svgContent = convertCircuitJsonToPcbSvg(soup) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, def) + + const pads = soup.filter( + (el): el is PcbPlatedHole => + el.type === "pcb_plated_hole" && el.port_hints?.[0] !== undefined, + ) + const pin1 = pads.find((p) => p.port_hints![0] === "1")! + const xs = pads.map((p) => p.x) + const ys = pads.map((p) => p.y) + const maxX = Math.max(...xs) + const minY = Math.min(...ys) + expect(pin1.x).toBe(maxX) + expect(pin1.y).toBe(minY) +}) + +test("pinrow12_rows2_startingpin(topside,rightpin)", () => { + const def = "pinrow12_rows2_startingpin(topside,rightpin)" + const soup = fp.string(def).circuitJson() + const svgContent = convertCircuitJsonToPcbSvg(soup) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, def) + + const pads = soup.filter( + (el): el is PcbPlatedHole => + el.type === "pcb_plated_hole" && el.port_hints?.[0] !== undefined, + ) + const pin1 = pads.find((p) => p.port_hints![0] === "1")! + const xs = pads.map((p) => p.x) + const ys = pads.map((p) => p.y) + const maxX = Math.max(...xs) + const maxY = Math.max(...ys) + expect(pin1.x).toBe(maxX) + expect(pin1.y).toBe(maxY) +}) + +test("pinrow12_rows2_startingpin(rightside,toppin)", () => { + const def = "pinrow12_rows2_startingpin(rightside,toppin)" + const soup = fp.string(def).circuitJson() + const svgContent = convertCircuitJsonToPcbSvg(soup) + expect(svgContent).toMatchSvgSnapshot(import.meta.path, def) + + const pads = soup.filter( + (el): el is PcbPlatedHole => + el.type === "pcb_plated_hole" && el.port_hints?.[0] !== undefined, + ) + const pin1 = pads.find((p) => p.port_hints![0] === "1")! + const xs = pads.map((p) => p.x) + const ys = pads.map((p) => p.y) + const maxX = Math.max(...xs) + const maxY = Math.max(...ys) + expect(pin1.x).toBe(maxX) + expect(pin1.y).toBe(maxY) +})