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
175 changes: 175 additions & 0 deletions tests/lib/check-via-to-pad-spacing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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 rectangular SMT 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: 0.5,
y: 0,
width: 0.4,
height: 0.3,
layer: "top",
},
]
// Via edge at 0.3, pad left edge at 0.3 => gap = 0mm, well below 0.2mm default
const errors = checkViaToPadSpacing(soup)
expect(errors).toHaveLength(1)
expect(errors[0].message).toContain("too close to pad")
})

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)
})

test("returns error when via is too close to a circular SMT 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: "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)
})

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")
})

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)
})

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)
})

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)
})
})
Copy link
Contributor

Choose a reason for hiding this comment

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

This test file violates the rule that states 'A *.test.ts file may have AT MOST one test(...), after that the user should split into multiple, numbered files. e.g. add1.test.ts, add2.test.ts'. The file contains 8 test() calls within a describe block (lines 6, 34, 60, 87, 117, 145, 161). To fix this violation, split the tests into multiple numbered files like check-via-to-pad-spacing1.test.ts, check-via-to-pad-spacing2.test.ts, etc., with each file containing only one test() call.

Spotted by Graphite (based on custom rule: Custom rule)

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.

This file was from an earlier commit and has already been removed. The tests are now split into individual files under tests/lib/check-via-to-pad-spacing/ (one test per file).