Skip to content
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export {
} from "./lib/run-all-checks"

export { checkConnectorAccessibleOrientation } from "./lib/check-connector-accessible-orientation"
export { checkViaToPadSpacing } from "./lib/check-via-to-pad-spacing"
177 changes: 177 additions & 0 deletions lib/check-via-to-pad-spacing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type {
AnyCircuitElement,
PcbVia,
PcbSmtPad,
PcbPlatedHole,
PcbViaClearanceError,
} from "circuit-json"
import { getReadableNameForElement } from "@tscircuit/circuit-json-util"
import { DEFAULT_VIA_TO_PAD_MARGIN, EPSILON } from "lib/drc-defaults"

type Pad = PcbSmtPad | PcbPlatedHole

/**
* Get the effective radius of a pad for clearance calculations.
* For rectangular pads, returns the half-diagonal (conservative bounding circle).
* For circular pads/plated holes, returns the actual radius.
*/
function getPadRadius(pad: Pad): number {
if (pad.type === "pcb_smtpad") {
if (pad.shape === "circle") return pad.radius
if (
pad.shape === "rect" ||
pad.shape === "rotated_rect" ||
pad.shape === "pill" ||
pad.shape === "rotated_pill"
) {
return Math.max(pad.width, pad.height) / 2
}
return 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation bug: Comment on line 15-16 claims the function "returns the half-diagonal (conservative bounding circle)" for rectangular pads, but the implementation at line 27 returns Math.max(pad.width, pad.height) / 2, not the actual half-diagonal.

The correct half-diagonal would be:

return Math.sqrt(pad.width ** 2 + pad.height ** 2) / 2

For a 1mm × 0.3mm pad:

  • Current code: 0.5mm
  • Correct diagonal: 0.523mm

This underestimates the bounding circle by ~4-5% for rectangular pads. While this fallback path is only used for unknown shapes (the main paths use proper rectangle-to-circle distance), it could still cause missed spacing violations for unsupported pad shapes.

Suggested change
function getPadRadius(pad: Pad): number {
if (pad.type === "pcb_smtpad") {
if (pad.shape === "circle") return pad.radius
if (
pad.shape === "rect" ||
pad.shape === "rotated_rect" ||
pad.shape === "pill" ||
pad.shape === "rotated_pill"
) {
return Math.max(pad.width, pad.height) / 2
}
return 0
function getPadRadius(pad: Pad): number {
if (pad.type === "pcb_smtpad") {
if (pad.shape === "circle") return pad.radius
if (
pad.shape === "rect" ||
pad.shape === "rotated_rect" ||
pad.shape === "pill" ||
pad.shape === "rotated_pill"
) {
return Math.sqrt(pad.width ** 2 + pad.height ** 2) / 2
}
return 0

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment and implementation are now consistent — the current code on line 27 uses Math.sqrt(pad.width ** 2 + pad.height ** 2) / 2 which is the half-diagonal. This was fixed in commit 0c04968. The bot may have been looking at an older version.

}
if (pad.type === "pcb_plated_hole") {
if (pad.shape === "circle") return pad.outer_diameter / 2
if (pad.shape === "oval" || pad.shape === "pill") {
return Math.max(pad.outer_width, pad.outer_height) / 2
}
if (pad.shape === "pill_hole_with_rect_pad") {
return Math.max(pad.rect_pad_width, pad.rect_pad_height) / 2
}
return 0
}
return 0
}

function getPadId(pad: Pad): string {
if (pad.type === "pcb_smtpad") return pad.pcb_smtpad_id
return pad.pcb_plated_hole_id
}

/**
* Compute the minimum distance between a via (circle) and a rectangular pad.
* Returns the edge-to-edge gap (negative if overlapping).
*/
function distanceViaToRectPad(
via: PcbVia,
pad: { x: number; y: number; width: number; height: number },
): number {
const halfW = pad.width / 2
const halfH = pad.height / 2
// Nearest point on rectangle to via center
const nearestX = Math.max(pad.x - halfW, Math.min(via.x, pad.x + halfW))
const nearestY = Math.max(pad.y - halfH, Math.min(via.y, pad.y + halfH))
const dist = Math.hypot(via.x - nearestX, via.y - nearestY)
return dist - via.outer_diameter / 2
}

/**
* Compute the edge-to-edge gap between a via and a circular pad.
*/
function distanceViaToCirclePad(
via: PcbVia,
padX: number,
padY: number,
padRadius: number,
): number {
const dist = Math.hypot(via.x - padX, via.y - padY)
return dist - via.outer_diameter / 2 - padRadius
}

/**
* Compute the edge-to-edge gap between a via and any pad type.
*/
function computeGap(via: PcbVia, pad: Pad): number {
if (pad.type === "pcb_smtpad") {
if (pad.shape === "circle") {
return distanceViaToCirclePad(via, pad.x, pad.y, pad.radius)
}
if (
pad.shape === "rect" ||
pad.shape === "rotated_rect" ||
pad.shape === "pill" ||
pad.shape === "rotated_pill"
) {
return distanceViaToRectPad(via, {
x: pad.x,
y: pad.y,
width: pad.width,
height: pad.height,
})
}
// Fallback: use bounding circle
return distanceViaToCirclePad(via, pad.x, pad.y, getPadRadius(pad))
}
if (pad.type === "pcb_plated_hole") {
if (pad.shape === "circle") {
return distanceViaToCirclePad(via, pad.x, pad.y, pad.outer_diameter / 2)
}
if (pad.shape === "oval" || pad.shape === "pill") {
return distanceViaToRectPad(via, {
x: pad.x,
y: pad.y,
width: pad.outer_width,
height: pad.outer_height,
})
}
if (pad.shape === "pill_hole_with_rect_pad") {
return distanceViaToRectPad(via, {
x: pad.x,
y: pad.y,
width: pad.rect_pad_width,
height: pad.rect_pad_height,
})
}
return distanceViaToCirclePad(via, pad.x, pad.y, getPadRadius(pad))
}
return Number.POSITIVE_INFINITY
}

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: Pad[] = [
...(circuitJson.filter((el) => el.type === "pcb_smtpad") as PcbSmtPad[]),
...(circuitJson.filter(
(el) => el.type === "pcb_plated_hole",
) as PcbPlatedHole[]),
]

if (vias.length === 0 || pads.length === 0) return []

const errors: PcbViaClearanceError[] = []

for (const via of vias) {
for (const pad of pads) {
const gap = computeGap(via, pad)
if (gap + EPSILON >= minSpacing) continue

const padId = getPadId(pad)
const pairId = [via.pcb_via_id, padId].sort().join("_")

errors.push({
type: "pcb_via_clearance_error",
pcb_error_id: `via_pad_close_${pairId}`,
message: `Via ${getReadableNameForElement(
circuitJson,
via.pcb_via_id,
)} is too close to pad ${getReadableNameForElement(
circuitJson,
padId,
)} (gap: ${gap.toFixed(3)}mm, minimum: ${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 + pad.x) / 2,
y: (via.y + pad.y) / 2,
},
})
}
}

return errors
}
1 change: 1 addition & 0 deletions lib/drc-defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions lib/run-all-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { checkPcbComponentOverlap } from "./check-pcb-components-overlap/checkPc
import { checkConnectorAccessibleOrientation } from "./check-connector-accessible-orientation"
import { checkPinMustBeConnected } from "./check-pin-must-be-connected"
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"
Expand All @@ -32,6 +33,7 @@ export async function runAllRoutingChecks(circuitJson: AnyCircuitElement[]) {
...checkEachPcbTraceNonOverlapping(circuitJson),
...checkSameNetViaSpacing(circuitJson),
...checkDifferentNetViaSpacing(circuitJson),
...checkViaToPadSpacing(circuitJson),
// ...checkTracesAreContiguous(circuitJson),
...checkPcbTracesOutOfBoard(circuitJson),
]
Expand Down
31 changes: 31 additions & 0 deletions tests/lib/check-via-to-pad-spacing/custom-min-spacing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { expect, test } from "bun:test"
import { checkViaToPadSpacing } from "lib/check-via-to-pad-spacing"
import type { AnyCircuitElement } from "circuit-json"

test("respects custom minSpacing parameter", () => {
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"],
},
{
type: "pcb_smtpad",
pcb_smtpad_id: "pad1",
shape: "rect",
x: 1.0,
y: 0,
width: 0.4,
height: 0.3,
layer: "top",
},
]
// Via edge at 0.3, pad left edge at 0.8 => gap = 0.5mm
// With default 0.2mm: no error. With 0.6mm: error
expect(checkViaToPadSpacing(soup)).toHaveLength(0)
expect(checkViaToPadSpacing(soup, { minSpacing: 0.6 })).toHaveLength(1)
})
18 changes: 18 additions & 0 deletions tests/lib/check-via-to-pad-spacing/no-pads.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { checkViaToPadSpacing } from "lib/check-via-to-pad-spacing"
import type { AnyCircuitElement } from "circuit-json"

test("returns empty array when 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"],
},
]
expect(checkViaToPadSpacing(soup)).toHaveLength(0)
})
19 changes: 19 additions & 0 deletions tests/lib/check-via-to-pad-spacing/no-vias.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { checkViaToPadSpacing } from "lib/check-via-to-pad-spacing"
import type { AnyCircuitElement } from "circuit-json"

test("returns empty array when no vias", () => {
const soup: AnyCircuitElement[] = [
{
type: "pcb_smtpad",
pcb_smtpad_id: "pad1",
shape: "rect",
x: 0,
y: 0,
width: 0.4,
height: 0.3,
layer: "top",
},
]
expect(checkViaToPadSpacing(soup)).toHaveLength(0)
})
30 changes: 30 additions & 0 deletions tests/lib/check-via-to-pad-spacing/via-close-to-circle-pad.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from "bun:test"
import { checkViaToPadSpacing } from "lib/check-via-to-pad-spacing"
import type { AnyCircuitElement } from "circuit-json"

test("no error when via gap to circular SMT pad equals minSpacing", () => {
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"],
},
{
type: "pcb_smtpad",
pcb_smtpad_id: "pad1",
shape: "circle",
x: 0.7,
y: 0,
radius: 0.2,
layer: "top",
},
]
// center-to-center = 0.7, via radius = 0.3, pad radius = 0.2 => gap = 0.2mm
// gap + EPSILON (0.005) >= 0.2mm minSpacing => no error
const errors = checkViaToPadSpacing(soup)
expect(errors).toHaveLength(0)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test"
import { checkViaToPadSpacing } from "lib/check-via-to-pad-spacing"
import type { AnyCircuitElement } from "circuit-json"

test("returns error when via is too close to a plated hole", () => {
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"],
},
{
type: "pcb_plated_hole",
pcb_plated_hole_id: "hole1",
shape: "circle",
x: 0.6,
y: 0,
hole_diameter: 0.3,
outer_diameter: 0.5,
layers: ["top", "bottom"],
pcb_component_id: "comp1",
pcb_port_id: "port1",
},
]
// center-to-center = 0.6, via radius = 0.3, hole radius = 0.25 => gap = 0.05mm < 0.2mm
const errors = checkViaToPadSpacing(soup)
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain("too close to pad")
})
29 changes: 29 additions & 0 deletions tests/lib/check-via-to-pad-spacing/via-far-from-pad.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test } from "bun:test"
import { checkViaToPadSpacing } from "lib/check-via-to-pad-spacing"
import type { AnyCircuitElement } from "circuit-json"

test("no error when via is far from pad", () => {
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"],
},
{
type: "pcb_smtpad",
pcb_smtpad_id: "pad1",
shape: "rect",
x: 2,
y: 0,
width: 0.4,
height: 0.3,
layer: "top",
},
]
const errors = checkViaToPadSpacing(soup)
expect(errors).toHaveLength(0)
})
Loading