diff --git a/index.ts b/index.ts index d616603..cb15c56 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,7 @@ export { checkViasOffBoard } from "./lib/check-pcb-components-out-of-board/check export { checkPcbComponentsOutOfBoard } from "./lib/check-pcb-components-out-of-board/checkPcbComponentsOutOfBoard" export { checkSameNetViaSpacing } from "./lib/check-same-net-via-spacing" export { checkDifferentNetViaSpacing } from "./lib/check-different-net-via-spacing" +export { checkViaToPadSpacing } from "./lib/check-via-to-pad-spacing" export { checkSourceTracesHavePcbTraces } from "./lib/check-source-traces-have-pcb-traces" export { checkPcbTracesOutOfBoard } from "./lib/check-trace-out-of-board/checkTraceOutOfBoard" export { checkPcbComponentOverlap } from "./lib/check-pcb-components-overlap/checkPcbComponentOverlap" diff --git a/lib/check-via-to-pad-spacing.ts b/lib/check-via-to-pad-spacing.ts new file mode 100644 index 0000000..2ccd4b3 --- /dev/null +++ b/lib/check-via-to-pad-spacing.ts @@ -0,0 +1,231 @@ +import type { + AnyCircuitElement, + PcbSmtPad, + PcbVia, + PcbViaClearanceError, +} from "circuit-json" +import { getReadableNameForElement } from "@tscircuit/circuit-json-util" +import { cju } from "@tscircuit/circuit-json-util" +import { DEFAULT_VIA_TO_PAD_MARGIN, EPSILON } from "lib/drc-defaults" +import { getLayersOfPcbElement } from "lib/util/getLayersOfPcbElement" +import type { Collidable } from "lib/check-each-pcb-trace-non-overlapping/getCollidableBounds" + +/** + * Compute the minimum edge-to-edge gap between a via (circle) and a pad. + * + * Via is treated as a circle with radius = outer_diameter / 2. + * Pad shapes handled: rect, circle, pill, rotated_rect, rotated_pill. + * Polygon pads fall back to a bounding-box approximation. + */ +function viaToSmtPadGap(via: PcbVia, pad: PcbSmtPad): number { + const viaRadius = via.outer_diameter / 2 + + if (pad.shape === "circle") { + const dist = Math.hypot(via.x - pad.x, via.y - pad.y) + return dist - viaRadius - pad.radius + } + + if (pad.shape === "rect") { + return rectToCircleGap( + pad.x, + pad.y, + pad.width, + pad.height, + via.x, + via.y, + viaRadius, + ) + } + + if (pad.shape === "rotated_rect") { + return rotatedRectToCircleGap( + pad.x, + pad.y, + pad.width, + pad.height, + pad.ccw_rotation, + via.x, + via.y, + viaRadius, + ) + } + + if (pad.shape === "pill") { + // A pill is a rect with semicircular ends. Treat as a rect-to-circle + // distance, then subtract the pill's corner radius. + const innerWidth = pad.width - 2 * pad.radius + const innerHeight = pad.height - 2 * pad.radius + const gap = rectToCircleGap( + pad.x, + pad.y, + Math.max(innerWidth, 0), + Math.max(innerHeight, 0), + via.x, + via.y, + viaRadius + pad.radius, + ) + return gap + } + + if (pad.shape === "rotated_pill") { + const innerWidth = pad.width - 2 * pad.radius + const innerHeight = pad.height - 2 * pad.radius + const gap = rotatedRectToCircleGap( + pad.x, + pad.y, + Math.max(innerWidth, 0), + Math.max(innerHeight, 0), + pad.ccw_rotation, + via.x, + via.y, + viaRadius + pad.radius, + ) + return gap + } + + // Polygon — bounding box fallback + if (pad.shape === "polygon" && pad.points?.length) { + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + for (const p of pad.points) { + if (p.x < minX) minX = p.x + if (p.x > maxX) maxX = p.x + if (p.y < minY) minY = p.y + if (p.y > maxY) maxY = p.y + } + const cx = (minX + maxX) / 2 + const cy = (minY + maxY) / 2 + const w = maxX - minX + const h = maxY - minY + return rectToCircleGap(cx, cy, w, h, via.x, via.y, viaRadius) + } + + // Unknown shape — return large gap so no false positive + return Number.POSITIVE_INFINITY +} + +/** + * Minimum gap between an axis-aligned rectangle (center rx, ry, dimensions w x h) + * and a circle (center cx, cy, radius cr). + */ +function rectToCircleGap( + rx: number, + ry: number, + w: number, + h: number, + cx: number, + cy: number, + cr: number, +): number { + const halfW = w / 2 + const halfH = h / 2 + // Nearest point on rect to circle center + const nearestX = Math.max(rx - halfW, Math.min(cx, rx + halfW)) + const nearestY = Math.max(ry - halfH, Math.min(cy, ry + halfH)) + const dist = Math.hypot(cx - nearestX, cy - nearestY) + return dist - cr +} + +/** + * Gap between a rotated rectangle and a circle. + * We rotate the circle center into the rectangle's local frame, then + * use the axis-aligned rect-to-circle formula. + */ +function rotatedRectToCircleGap( + rx: number, + ry: number, + w: number, + h: number, + ccwRotation: number, + cx: number, + cy: number, + cr: number, +): number { + // Translate circle center relative to rect center + const dx = cx - rx + const dy = cy - ry + // Rotate into rect-local frame (negate the CCW angle) + const cos = Math.cos(-ccwRotation) + const sin = Math.sin(-ccwRotation) + const localX = dx * cos - dy * sin + const localY = dx * sin + dy * cos + return rectToCircleGap(0, 0, w, h, localX, localY, cr) +} + +/** + * Return the center point of any PcbSmtPad shape. + * Polygon pads use the bounding-box centroid; all others have x/y directly. + */ +function getPadCenter(pad: PcbSmtPad): { x: number; y: number } { + if (pad.shape === "polygon") { + const pts = pad.points ?? [] + if (pts.length === 0) return { x: 0, y: 0 } + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + for (const p of pts) { + if (p.x < minX) minX = p.x + if (p.x > maxX) maxX = p.x + if (p.y < minY) minY = p.y + if (p.y > maxY) maxY = p.y + } + return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 } + } + return { x: pad.x, y: pad.y } +} + +function doLayersOverlap(layersA: string[], layersB: string[]): boolean { + if (layersA.length === 0 || layersB.length === 0) return true + return layersA.some((l) => layersB.includes(l)) +} + +export function checkViaToPadSpacing( + circuitJson: AnyCircuitElement[], + { minSpacing = DEFAULT_VIA_TO_PAD_MARGIN }: { minSpacing?: number } = {}, +): PcbViaClearanceError[] { + const vias = circuitJson.filter((el) => el.type === "pcb_via") as PcbVia[] + const pads = cju(circuitJson).pcb_smtpad.list() as PcbSmtPad[] + if (vias.length === 0 || pads.length === 0) return [] + + const errors: PcbViaClearanceError[] = [] + const reported = new Set() + + for (const via of vias) { + const viaLayers = getLayersOfPcbElement(via as unknown as Collidable) + + for (const pad of pads) { + const padLayers = getLayersOfPcbElement(pad as unknown as Collidable) + if (!doLayersOverlap(viaLayers, padLayers)) continue + + const gap = viaToSmtPadGap(via, pad) + if (gap + EPSILON >= minSpacing) continue + + const pairId = [via.pcb_via_id, pad.pcb_smtpad_id].sort().join("_") + if (reported.has(pairId)) continue + reported.add(pairId) + + const viaName = getReadableNameForElement(circuitJson, via.pcb_via_id) + const padName = getReadableNameForElement(circuitJson, pad.pcb_smtpad_id) + + const padCenter = getPadCenter(pad) + errors.push({ + type: "pcb_via_clearance_error", + pcb_error_id: `via_to_pad_close_${pairId}`, + message: `Via ${viaName} is too close to pad ${padName} (gap: ${gap.toFixed(3)}mm, min: ${minSpacing}mm)`, + error_type: "pcb_via_clearance_error", + pcb_via_ids: [via.pcb_via_id], + minimum_clearance: minSpacing, + actual_clearance: gap, + pcb_center: { + x: (via.x + padCenter.x) / 2, + y: (via.y + padCenter.y) / 2, + }, + }) + } + } + + return errors +} diff --git a/lib/drc-defaults.ts b/lib/drc-defaults.ts index cf578e9..959be96 100644 --- a/lib/drc-defaults.ts +++ b/lib/drc-defaults.ts @@ -5,5 +5,6 @@ export const DEFAULT_VIA_BOARD_MARGIN = 0.3 export const DEFAULT_SAME_NET_VIA_MARGIN = 0.2 export const DEFAULT_DIFFERENT_NET_VIA_MARGIN = 0.3 +export const DEFAULT_VIA_TO_PAD_MARGIN = 0.2 export const EPSILON = 0.005 diff --git a/lib/run-all-checks.ts b/lib/run-all-checks.ts index 325733f..5cf9423 100644 --- a/lib/run-all-checks.ts +++ b/lib/run-all-checks.ts @@ -12,6 +12,7 @@ import { checkPinMustBeConnected } from "./check-pin-must-be-connected" import { checkNoGroundPinDefined } from "./check-no-ground-pin-defined" import { checkNoPowerPinDefined } from "./check-no-power-pin-defined" import { checkSameNetViaSpacing } from "./check-same-net-via-spacing" +import { checkViaToPadSpacing } from "./check-via-to-pad-spacing" import { checkSourceTracesHavePcbTraces } from "./check-source-traces-have-pcb-traces" import { checkPcbTracesOutOfBoard } from "./check-trace-out-of-board/checkTraceOutOfBoard" import { checkTracesAreContiguous } from "./check-traces-are-contiguous/check-traces-are-contiguous" @@ -47,6 +48,7 @@ export async function runAllRoutingChecks(circuitJson: AnyCircuitElement[]) { ...checkEachPcbTraceNonOverlapping(circuitJson), ...checkSameNetViaSpacing(circuitJson), ...checkDifferentNetViaSpacing(circuitJson), + ...checkViaToPadSpacing(circuitJson), // ...checkTracesAreContiguous(circuitJson), ...checkPcbTracesOutOfBoard(circuitJson), ] diff --git a/tests/lib/check-via-to-pad-spacing.test.ts b/tests/lib/check-via-to-pad-spacing.test.ts new file mode 100644 index 0000000..a06d8ad --- /dev/null +++ b/tests/lib/check-via-to-pad-spacing.test.ts @@ -0,0 +1,403 @@ +import { expect, test, describe } from "bun:test" +import { checkViaToPadSpacing } from "lib/check-via-to-pad-spacing" +import type { AnyCircuitElement } from "circuit-json" + +describe("checkViaToPadSpacing", () => { + test("returns error when via is too close to a rect pad on same layer", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "rect", + x: 0, + y: 0, + width: 1.0, + height: 0.5, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + x: 0.7, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + // Pad right edge at x=0.5, via left edge at x=0.7-0.3=0.4 → gap = -0.1 (overlapping) + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(1) + expect(errors[0].message).toContain("too close to pad") + }) + + test("no error when via is far enough from rect pad", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "rect", + x: 0, + y: 0, + width: 1.0, + height: 0.5, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + x: 1.2, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + // Pad right edge at x=0.5, via left edge at x=1.2-0.3=0.9 → gap = 0.4 + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(0) + }) + + test("no error when via and pad are on different layers", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "rect", + x: 0, + y: 0, + width: 1.0, + height: 0.5, + layer: "bottom", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + x: 0.5, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top"], + }, + ] as AnyCircuitElement[] + + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(0) + }) + + test("returns error when via is too close to a circle pad", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "circle", + x: 0, + y: 0, + radius: 0.5, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + x: 0.8, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + // Center-to-center: 0.8, pad radius: 0.5, via radius: 0.3 → gap = 0.0 < 0.2 + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(1) + expect(errors[0].message).toContain("too close to pad") + }) + + test("no error when via is far enough from circle pad", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "circle", + x: 0, + y: 0, + radius: 0.5, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + x: 1.2, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + // Center-to-center: 1.2, pad radius: 0.5, via radius: 0.3 → gap = 0.4 + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(0) + }) + + test("handles pill-shaped pads correctly", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "pill", + x: 0, + y: 0, + width: 2.0, + height: 1.0, + radius: 0.5, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + // Place just outside the pill's right semicircle end + x: 1.3, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + // Inner rect: width=1.0, height=0.0 (pill radius=0.5, height=1.0 → inner height=0) + // Effective radius = via_radius(0.3) + pill_radius(0.5) = 0.8 + // Nearest point on inner rect at origin to via center (1.3, 0): distance from (0.5, 0) to (1.3, 0) = 0.8 + // gap = 0.8 - 0.8 = 0.0 < 0.2 + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(1) + }) + + test("returns no errors when there are no vias", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "rect", + x: 0, + y: 0, + width: 1.0, + height: 0.5, + layer: "top", + }, + ] as AnyCircuitElement[] + + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(0) + }) + + test("returns no errors when there are no pads", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_via", + pcb_via_id: "via1", + x: 0, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(0) + }) + + test("respects custom minSpacing parameter", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "rect", + x: 0, + y: 0, + width: 1.0, + height: 0.5, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + x: 1.0, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + // Gap: nearest point on rect (0.5, 0) to via center (1.0, 0) = 0.5, minus via radius 0.3 = 0.2 + // With default minSpacing=0.2, passes (gap == minSpacing) + expect(checkViaToPadSpacing(soup)).toHaveLength(0) + + // With larger minSpacing=0.3, fails + expect(checkViaToPadSpacing(soup, { minSpacing: 0.3 })).toHaveLength(1) + }) + + test("returns error when via is too close to a rotated_rect pad", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "rotated_rect", + x: 0, + y: 0, + width: 2.0, + height: 0.5, + // 90° CCW rotation swaps the long axis to vertical + ccw_rotation: Math.PI / 2, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + // Place via to the right. After rotation the pad extends ±0.25 in x + // and ±1.0 in y. Via left edge at 0.35-0.3=0.05, pad right edge at 0.25 + // → gap ≈ -0.2 (overlapping) + x: 0.35, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(1) + expect(errors[0].message).toContain("too close to pad") + }) + + test("no error when via is far enough from rotated_rect pad", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "rotated_rect", + x: 0, + y: 0, + width: 2.0, + height: 0.5, + // 90° CCW rotation swaps the long axis to vertical + ccw_rotation: Math.PI / 2, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + // After rotation pad extends ±0.25 in x. Via left edge at 1.0-0.3=0.7 + // → gap = 0.7-0.25 = 0.45 > 0.2 + x: 1.0, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(0) + }) + + test("returns error when via is too close to a rotated_pill pad", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "rotated_pill", + x: 0, + y: 0, + width: 2.0, + height: 1.0, + radius: 0.5, + // 90° CCW rotation swaps long axis to vertical + ccw_rotation: Math.PI / 2, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + // After rotation: inner rect is 1.0 x 0.0 rotated 90° → extends 0 in x. + // Effective radius = via_radius(0.3) + pill_radius(0.5) = 0.8 + // Distance from origin to (0.6, 0) = 0.6, gap = 0.6 - 0.8 = -0.2 (overlapping) + x: 0.6, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(1) + expect(errors[0].message).toContain("too close to pad") + }) + + test("no error when via is far enough from rotated_pill pad", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "rotated_pill", + x: 0, + y: 0, + width: 2.0, + height: 1.0, + radius: 0.5, + ccw_rotation: Math.PI / 2, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + // Effective radius = 0.3 + 0.5 = 0.8. Via center at 1.5 + // Distance from inner rect center (0,0) to (1.5,0) = 1.5 + // gap = 1.5 - 0.8 = 0.7 > 0.2 + x: 1.5, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(0) + }) + + test("detects via too close to pad diagonally", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + shape: "rect", + x: 0, + y: 0, + width: 1.0, + height: 1.0, + layer: "top", + }, + { + type: "pcb_via", + pcb_via_id: "via1", + // Near the corner of the pad + x: 0.7, + y: 0.7, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + ] as AnyCircuitElement[] + + // Nearest rect point to (0.7, 0.7) is (0.5, 0.5), distance = sqrt(0.04+0.04) ≈ 0.283 + // gap = 0.283 - 0.3 ≈ -0.017 (overlapping) + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(1) + }) +})