diff --git a/index.ts b/index.ts index a24e833..fcf062f 100644 --- a/index.ts +++ b/index.ts @@ -11,6 +11,7 @@ export { checkPcbTracesOutOfBoard } from "./lib/check-trace-out-of-board/checkTr export { checkPcbComponentOverlap } from "./lib/check-pcb-components-overlap/checkPcbComponentOverlap" export { checkPadPadClearance } from "./lib/check-pad-pad-clearance" export { checkPadTraceClearance } from "./lib/check-pad-trace-clearance" +export { checkViaPadClearance } from "./lib/check-via-pad-clearance" export { checkViaTraceClearance } from "./lib/check-via-trace-clearance" export { checkPinMustBeConnected } from "./lib/check-pin-must-be-connected" export { checkAllPinsInComponentAreUnderspecified } from "./lib/check-all-pins-in-component-are-underspecified" diff --git a/lib/check-pad-clearance/common.ts b/lib/check-pad-clearance/common.ts index 5596e7b..b66ab66 100644 --- a/lib/check-pad-clearance/common.ts +++ b/lib/check-pad-clearance/common.ts @@ -41,7 +41,7 @@ export const getPadRadius = (pad: PadElement) => { export const isCircularPad = (pad: PadElement) => pad.shape === "circle" -const getCircleShape = (pad: PadElement) => { +export const getCircleShape = (pad: PadElement) => { const center = getPadCenter(pad) return { kind: "circle" as const, @@ -51,7 +51,7 @@ const getCircleShape = (pad: PadElement) => { } } -const getPolygonShape = (pad: PadElement) => { +export const getPolygonShape = (pad: PadElement) => { const bounds = getPadBounds(pad) return { kind: "polygon" as const, diff --git a/lib/check-via-pad-clearance.ts b/lib/check-via-pad-clearance.ts new file mode 100644 index 0000000..29d924e --- /dev/null +++ b/lib/check-via-pad-clearance.ts @@ -0,0 +1,156 @@ +import { + getPrimaryId, + getReadableNameForElement, +} from "@tscircuit/circuit-json-util" +import { + distanceBetweenCircleAndCircle, + distanceBetweenCircleAndPolygon, +} from "@tscircuit/circuit-json-util" +import type { AnyCircuitElement, PcbVia } from "circuit-json" +import { + type ConnectivityMap, + getFullConnectivityMapFromCircuitJson, +} from "circuit-json-to-connectivity-map" +import { SpatialObjectIndex } from "lib/data-structures/SpatialIndex" +import { EPSILON, getBoardDrcValue, getPcbBoard } from "lib/drc-defaults" +import { getLayersOfPcbElement } from "lib/util/getLayersOfPcbElement" +import { + type PadElement, + formatMm, + getCircleShape, + getPadBounds, + getPadCenter, + getPads, + isCircularPad, + getPolygonShape, +} from "./check-pad-clearance/common" +import { jlcMinTolerances } from "@tscircuit/jlcpcb-manufacturing-specs" + +/** + * Error emitted when a via is closer to a pad than the allowed clearance. + * + * `circuit-json` does not yet define a dedicated `pcb_via_pad_clearance_error` + * type, so it is declared locally. Promoting it to `circuit-json` would be a + * natural follow-up. + */ +export interface PcbViaPadClearanceError { + type: "pcb_via_pad_clearance_error" + pcb_via_pad_clearance_error_id: string + error_type: "pcb_via_pad_clearance_error" + message: string + pcb_via_id: string + pcb_pad_id: string + minimum_clearance: number + actual_clearance: number + center: { x: number; y: number } +} + +/** + * Minimum edge-to-edge distance between a via (treated as a circle of + * outer_diameter) and a pad. Uses `getBoundsOfPcbElements` for the pad + * so that all pad shapes (circle, rect, pill, rotated_pill, polygon, etc.) + * are handled correctly - the pad is conservatively approximated as either + * a circle or an axis-aligned bounding-box polygon. + */ +const getViaToPadGap = ( + via: PcbVia, + pad: PadElement, +): number => { + const viaCircle = { + kind: "circle" as const, + x: via.x, + y: via.y, + radius: via.outer_diameter / 2, + } + + if (isCircularPad(pad)) { + return distanceBetweenCircleAndCircle(viaCircle, getCircleShape(pad)) + } + + return distanceBetweenCircleAndPolygon(viaCircle, getPolygonShape(pad)) +} + +/** + * Checks that vias are not placed too close to pads (SMT pads and plated + * holes). A via connected to the same net as the pad is ignored. + */ +export function checkViaPadClearance( + circuitJson: AnyCircuitElement[], + { + connMap, + minClearance, + }: { connMap?: ConnectivityMap; minClearance?: number } = {}, +): PcbViaPadClearanceError[] { + const vias = circuitJson.filter((el) => el.type === "pcb_via") as PcbVia[] + const pads = getPads(circuitJson) + if (vias.length === 0 || pads.length === 0) return [] + + const board = getPcbBoard(circuitJson) + minClearance ??= + getBoardDrcValue(board, "min_pad_edge_to_pad_edge_clearance") ?? + jlcMinTolerances.min_pad_edge_to_pad_edge_clearance + connMap ??= getFullConnectivityMapFromCircuitJson(circuitJson) + + const spatialIndex = new SpatialObjectIndex({ + objects: pads, + getBounds: getPadBounds, + getId: (pad) => getPrimaryId(pad), + }) + + const errors = new Map< + string, + { error: PcbViaPadClearanceError; gap: number } + >() + + for (const via of vias) { + const viaLayers = getLayersOfPcbElement(via) + const viaBounds = { + minX: via.x - via.outer_diameter / 2, + minY: via.y - via.outer_diameter / 2, + maxX: via.x + via.outer_diameter / 2, + maxY: via.y + via.outer_diameter / 2, + } + const nearbyPads = spatialIndex.getObjectsInBounds( + viaBounds, + minClearance, + ) + + for (const pad of nearbyPads) { + const padId = getPrimaryId(pad) + + // Only compare elements that share at least one copper layer. + const padLayers = getLayersOfPcbElement(pad as any) + if (!viaLayers.some((layer) => padLayers.includes(layer))) continue + + // A via intentionally connected to the pad's net is not a violation. + if (connMap.areIdsConnected(via.pcb_via_id, padId)) continue + + const gap = getViaToPadGap(via, pad) + if (gap + EPSILON >= minClearance!) continue + + const pairId = `${via.pcb_via_id}_${padId}` + const padCenter = getPadCenter(pad) + const nextError: PcbViaPadClearanceError = { + type: "pcb_via_pad_clearance_error", + pcb_via_pad_clearance_error_id: `via_pad_clearance_${pairId}`, + error_type: "pcb_via_pad_clearance_error", + message: `Via ${getReadableNameForElement(circuitJson, via.pcb_via_id)} and pad ${getReadableNameForElement(circuitJson, padId)} are too close (clearance: ${formatMm(gap)}, minimum: ${formatMm(minClearance!)})`, + pcb_via_id: via.pcb_via_id, + pcb_pad_id: padId, + minimum_clearance: minClearance!, + actual_clearance: gap, + center: { + x: (via.x + padCenter.x) / 2, + y: (via.y + padCenter.y) / 2, + }, + } + + const current = errors.get(pairId) + if (!current || gap < current.gap) { + errors.set(pairId, { error: nextError, gap }) + } + } + } + + return Array.from(errors.values()).map(({ error }) => error) +} diff --git a/lib/run-all-checks.ts b/lib/run-all-checks.ts index ec5aa16..f7b7241 100644 --- a/lib/run-all-checks.ts +++ b/lib/run-all-checks.ts @@ -17,6 +17,7 @@ import { checkSameNetViaSpacing } from "./check-same-net-via-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" +import { checkViaPadClearance } from "./check-via-pad-clearance" import { checkViaTraceClearance } from "./check-via-trace-clearance" export async function runAllPlacementChecks(circuitJson: AnyCircuitElement[]) { @@ -50,6 +51,7 @@ export async function runAllRoutingChecks(circuitJson: AnyCircuitElement[]) { ...checkEachPcbPortConnectedToPcbTraces(circuitJson), ...checkSourceTracesHavePcbTraces(circuitJson), ...checkEachPcbTraceNonOverlapping(circuitJson), + ...checkViaPadClearance(circuitJson), ...checkViaTraceClearance(circuitJson), ...checkSameNetViaSpacing(circuitJson), ...checkDifferentNetViaSpacing(circuitJson), diff --git a/tests/lib/check-via-pad-clearance.test.ts b/tests/lib/check-via-pad-clearance.test.ts new file mode 100644 index 0000000..33a0037 --- /dev/null +++ b/tests/lib/check-via-pad-clearance.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test" +import type { AnyCircuitElement } from "circuit-json" +import { checkViaPadClearance } from "../../lib/check-via-pad-clearance" + +const makeBoard = (): AnyCircuitElement => + ({ + type: "pcb_board", + pcb_board_id: "board1", + width: 100, + height: 100, + x: 0, + y: 0, + }) as AnyCircuitElement + +const makeVia = (id: string, x: number, y: number): AnyCircuitElement => + ({ + type: "pcb_via", + pcb_via_id: id, + x, + y, + outer_diameter: 1.0, + hole_diameter: 0.4, + layers: ["top", "bottom"], + }) as AnyCircuitElement + +const makeCirclePad = (id: string, x: number, y: number): AnyCircuitElement => + ({ + type: "pcb_smtpad", + pcb_smtpad_id: id, + pcb_component_id: "comp1", + pcb_port_id: `port_${id}`, + shape: "circle", + x, + y, + radius: 0.5, + layer: "top", + }) as AnyCircuitElement + +const makeRectPad = (id: string, x: number, y: number): AnyCircuitElement => + ({ + type: "pcb_smtpad", + pcb_smtpad_id: id, + pcb_component_id: "comp1", + pcb_port_id: `port_${id}`, + shape: "rect", + x, + y, + width: 1.0, + height: 1.0, + layer: "top", + }) as AnyCircuitElement + +const makePlatedHole = (id: string, x: number, y: number): AnyCircuitElement => + ({ + type: "pcb_plated_hole", + pcb_plated_hole_id: id, + pcb_component_id: "comp1", + pcb_port_id: `port_${id}`, + shape: "circle", + x, + y, + hole_diameter: 0.8, + outer_diameter: 1.2, + layers: ["top", "bottom"], + }) as AnyCircuitElement + +test("via too close to circle pad should produce error", () => { + const circuitJson: AnyCircuitElement[] = [ + makeBoard(), + makeVia("via1", 0, 0), + // via radius=0.5, pad radius=0.5, distance=1.05 => gap=0.05mm < 0.1mm default + makeCirclePad("pad1", 1.05, 0), + ] + const errors = checkViaPadClearance(circuitJson) + expect(errors.length).toBeGreaterThan(0) + expect(errors[0].type).toBe("pcb_via_pad_clearance_error") + expect(errors[0].pcb_via_id).toBe("via1") + expect(errors[0].pcb_pad_id).toBe("pad1") +}) + +test("via far from pad should not produce error", () => { + const circuitJson: AnyCircuitElement[] = [ + makeBoard(), + makeVia("via1", 0, 0), + makeCirclePad("pad1", 10, 0), + ] + const errors = checkViaPadClearance(circuitJson) + expect(errors.length).toBe(0) +}) + +test("via too close to rect pad should produce error", () => { + const circuitJson: AnyCircuitElement[] = [ + makeBoard(), + makeVia("via1", 0, 0), + // via radius=0.5, rect half-width=0.5, center at 1.04 + // rect nearest edge at x=0.54, gap≈0.04mm < 0.1mm default + makeRectPad("pad1", 1.04, 0), + ] + const errors = checkViaPadClearance(circuitJson) + expect(errors.length).toBeGreaterThan(0) +}) + +test("via too close to plated hole should produce error", () => { + const circuitJson: AnyCircuitElement[] = [ + makeBoard(), + makeVia("via1", 0, 0), + // via radius=0.5, plated_hole outer radius=0.6, distance=1.14 + // gap=1.14-0.5-0.6=0.04mm < 0.1mm default + makePlatedHole("ph1", 1.14, 0), + ] + const errors = checkViaPadClearance(circuitJson) + expect(errors.length).toBeGreaterThan(0) +}) + +test("custom minClearance is respected", () => { + const circuitJson: AnyCircuitElement[] = [ + makeBoard(), + makeVia("via1", 0, 0), + makeCirclePad("pad1", 3, 0), // gap = 3 - 0.5 - 0.5 = 2.0mm (passes default) + ] + // Default clearance (~0.1mm) should pass + const defaultErrors = checkViaPadClearance(circuitJson) + expect(defaultErrors.length).toBe(0) + + // Very large minClearance should fail + const customErrors = checkViaPadClearance(circuitJson, { + minClearance: 5, + }) + expect(customErrors.length).toBeGreaterThan(0) +}) + +test("no vias returns empty", () => { + const circuitJson: AnyCircuitElement[] = [ + makeBoard(), + makeCirclePad("pad1", 0, 0), + ] + const errors = checkViaPadClearance(circuitJson) + expect(errors.length).toBe(0) +}) + +test("no pads returns empty", () => { + const circuitJson: AnyCircuitElement[] = [ + makeBoard(), + makeVia("via1", 0, 0), + ] + const errors = checkViaPadClearance(circuitJson) + expect(errors.length).toBe(0) +})