diff --git a/index.ts b/index.ts index a24e833..de1d365 100644 --- a/index.ts +++ b/index.ts @@ -12,6 +12,7 @@ export { checkPcbComponentOverlap } from "./lib/check-pcb-components-overlap/che export { checkPadPadClearance } from "./lib/check-pad-pad-clearance" export { checkPadTraceClearance } from "./lib/check-pad-trace-clearance" export { checkViaTraceClearance } from "./lib/check-via-trace-clearance" +export { checkViaPadClearance } from "./lib/check-via-pad-clearance" export { checkPinMustBeConnected } from "./lib/check-pin-must-be-connected" export { checkAllPinsInComponentAreUnderspecified } from "./lib/check-all-pins-in-component-are-underspecified" export { checkNoPowerPinDefined } from "./lib/check-no-power-pin-defined" diff --git a/lib/check-via-pad-clearance.ts b/lib/check-via-pad-clearance.ts new file mode 100644 index 0000000..a68afec --- /dev/null +++ b/lib/check-via-pad-clearance.ts @@ -0,0 +1,147 @@ +import { getReadableNameForElement } from "@tscircuit/circuit-json-util" +import { + distanceBetweenCircleAndCircle, + distanceBetweenCircleAndPolygon, +} from "@tscircuit/circuit-json-util" +import type { + AnyCircuitElement, + PcbPadPadClearanceError, + PcbVia, +} from "circuit-json" +import { + type ConnectivityMap, + getFullConnectivityMapFromCircuitJson, +} from "circuit-json-to-connectivity-map" +import { EPSILON, getBoardDrcValue, getPcbBoard } from "lib/drc-defaults" +import { + formatMm, + getPadCenter, + getPadRadius, + getPads, + isCircularPad, + type PadElement, +} from "./check-pad-clearance/common" +import { jlcMinTolerances } from "@tscircuit/jlcpcb-manufacturing-specs" + +const getPadId = (pad: PadElement): string => { + if ("pcb_smtpad_id" in pad) return pad.pcb_smtpad_id + if ("pcb_plated_hole_id" in pad) return pad.pcb_plated_hole_id + return "" +} + +const getViaToPadGap = (via: PcbVia, pad: PadElement): number => { + const viaRadius = via.outer_diameter / 2 + const viaCircle = { + kind: "circle" as const, + x: via.x, + y: via.y, + radius: viaRadius, + } + + const padCenter = getPadCenter(pad) + const padRadius = getPadRadius(pad) + + if (isCircularPad(pad)) { + return distanceBetweenCircleAndCircle(viaCircle, { + kind: "circle" as const, + x: padCenter.x, + y: padCenter.y, + radius: padRadius, + }) + } + + // For rectangular / polygon pads, use circle-to-polygon distance. + // We approximate using the pad bounding box, which is consistent with + // how check-pad-clearance handles non-circular pads. + return distanceBetweenCircleAndPolygon(viaCircle, { + kind: "polygon" as const, + points: (() => { + // Build a bounding-box polygon from the pad center + radius. + // getPadRadius returns half of the smaller bounding dimension, so this + // is conservative (tighter than the actual shape). + const cx = padCenter.x + const cy = padCenter.y + const r = padRadius + return [ + { x: cx - r, y: cy - r }, + { x: cx + r, y: cy - r }, + { x: cx + r, y: cy + r }, + { x: cx - r, y: cy + r }, + ] + })(), + }) +} + +/** + * Check that vias are not too close to pads on different nets. + * + * A via's copper annular ring must maintain minimum clearance from nearby SMT + * pads and plated holes that belong to a different electrical net. Violating + * this rule risks short-circuits during soldering or manufacturing. + * + * Related issue: https://github.com/tscircuit/checks/issues/44 + */ +export function checkViaPadClearance( + circuitJson: AnyCircuitElement[], + { + connMap, + minClearance, + }: { connMap?: ConnectivityMap; minClearance?: number } = {}, +): PcbPadPadClearanceError[] { + 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_trace_to_pad_edge_clearance") ?? + jlcMinTolerances.min_trace_to_pad_edge_clearance + + connMap ??= getFullConnectivityMapFromCircuitJson(circuitJson) + + const errors: PcbPadPadClearanceError[] = [] + const reported = new Set() + + for (const via of vias) { + for (const pad of pads) { + const padId = getPadId(pad) + if (!padId) continue + + // Skip pads electrically connected to this via + if (connMap.areIdsConnected(via.pcb_via_id, padId)) continue + + // TODO: use flatbush for spatial indexing to avoid O(n*m) loop + const gap = getViaToPadGap(via, pad) + if (gap + EPSILON >= minClearance!) continue + + const pairId = [via.pcb_via_id, padId].sort().join("_") + if (reported.has(pairId)) continue + reported.add(pairId) + + const padCenter = getPadCenter(pad) + + errors.push({ + type: "pcb_pad_pad_clearance_error", + pcb_pad_pad_clearance_error_id: `via_pad_clearance_${pairId}`, + error_type: "pcb_pad_pad_clearance_error", + message: `Via ${getReadableNameForElement( + circuitJson, + via.pcb_via_id, + )} is too close to pad ${getReadableNameForElement( + circuitJson, + padId, + )} (gap: ${formatMm(gap)}, minimum: ${formatMm(minClearance!)})`, + pcb_pad_ids: [via.pcb_via_id, padId], + minimum_clearance: minClearance, + actual_clearance: gap, + center: { + x: (via.x + padCenter.x) / 2, + y: (via.y + padCenter.y) / 2, + }, + }) + } + } + + return errors +} diff --git a/lib/run-all-checks.ts b/lib/run-all-checks.ts index ec5aa16..4deab91 100644 --- a/lib/run-all-checks.ts +++ b/lib/run-all-checks.ts @@ -18,6 +18,7 @@ import { checkSourceTracesHavePcbTraces } from "./check-source-traces-have-pcb-t import { checkPcbTracesOutOfBoard } from "./check-trace-out-of-board/checkTraceOutOfBoard" import { checkTracesAreContiguous } from "./check-traces-are-contiguous/check-traces-are-contiguous" import { checkViaTraceClearance } from "./check-via-trace-clearance" +import { checkViaPadClearance } from "./check-via-pad-clearance" export async function runAllPlacementChecks(circuitJson: AnyCircuitElement[]) { return [ @@ -26,6 +27,7 @@ export async function runAllPlacementChecks(circuitJson: AnyCircuitElement[]) { ...checkPcbComponentOverlap(circuitJson), ...checkPadPadClearance(circuitJson), ...checkPadTraceClearance(circuitJson), + ...checkViaPadClearance(circuitJson), ...checkCourtyardOverlap(circuitJson), ...checkConnectorAccessibleOrientation(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..6d4f21f --- /dev/null +++ b/tests/lib/check-via-pad-clearance.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test" +import type { AnyCircuitElement } from "circuit-json" +import { checkViaPadClearance } from "lib/check-via-pad-clearance" + +// Via outer_diameter = 0.6mm → radius = 0.3mm +// SMT pad radius ≈ 0.1mm (0.2×0.2 circle) +// Min clearance default ≈ 0.1mm (JLCPCB min_trace_to_pad_edge_clearance) + +describe("checkViaPadClearance", () => { + test("returns error when via is too close to pad on a different net", () => { + const soup: AnyCircuitElement[] = [ + // Two independent source traces so the via and pad are on different nets + { type: "source_trace", source_trace_id: "st1", connected_source_port_ids: [], connected_source_net_ids: [] }, + { type: "source_trace", source_trace_id: "st2", connected_source_port_ids: [], connected_source_net_ids: [] }, + { + type: "pcb_via", + pcb_via_id: "via1", + pcb_trace_id: "trace1", + x: 0, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + // Place pad 0.35mm away — via radius (0.3) + pad radius (~0.1) = 0.4mm edge-to-edge clearance would be needed; + // gap = 0.35 - 0.3 - 0.1 = -0.05mm → violation + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + pcb_component_id: "comp1", + pcb_port_id: "port1", + x: 0.35, + y: 0, + width: 0.2, + height: 0.2, + shape: "circle" as const, + layer: "top", + port_hints: [], + }, + ] + + const errors = checkViaPadClearance(soup, { minClearance: 0.1 }) + expect(errors.length).toBeGreaterThan(0) + expect(errors[0].message).toContain("too close") + expect(errors[0].actual_clearance).toBeLessThan(0.1) + }) + + test("no error when via is sufficiently far from pad on a different net", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_via", + pcb_via_id: "via1", + pcb_trace_id: "trace1", + x: 0, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + pcb_component_id: "comp1", + pcb_port_id: "port1", + x: 2, + y: 0, + width: 0.2, + height: 0.2, + shape: "circle" as const, + layer: "top", + port_hints: [], + }, + ] + + const errors = checkViaPadClearance(soup, { minClearance: 0.1 }) + expect(errors).toHaveLength(0) + }) + + test("no error when via and pad are on the same net (via connMap)", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_via", + pcb_via_id: "via1", + pcb_trace_id: "trace1", + x: 0, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + }, + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + pcb_component_id: "comp1", + pcb_port_id: "port1", + x: 0.35, + y: 0, + width: 0.2, + height: 0.2, + shape: "circle" as const, + layer: "top", + port_hints: [], + }, + ] + + // Inject a connMap that marks via1 and pad1 as connected (same net) + const connMap = { + areIdsConnected: (a: string, b: string) => + (a === "via1" && b === "pad1") || (a === "pad1" && b === "via1"), + getIdsConnectedToId: () => [], + getNetIdForId: () => undefined, + } as any + + const errors = checkViaPadClearance(soup, { minClearance: 0.1, connMap }) + expect(errors).toHaveLength(0) + }) + + test("returns no errors when there are no vias", () => { + const soup: AnyCircuitElement[] = [ + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + pcb_component_id: "comp1", + pcb_port_id: "port1", + x: 0, + y: 0, + width: 0.5, + height: 0.5, + shape: "rect" as const, + layer: "top", + port_hints: [], + }, + ] + + const errors = checkViaPadClearance(soup, { minClearance: 0.1 }) + expect(errors).toHaveLength(0) + }) +})