From 3b48bda999f8776f63b515b5eeece4554cbb840d Mon Sep 17 00:00:00 2001 From: fengfirewudi Date: Wed, 15 Apr 2026 17:26:20 -0400 Subject: [PATCH 1/3] Add DRC check for vias too close to pads Implements a new check that detects when vias are too close to pads (pcb_smtpad and pcb_plated_hole), using rect-to-circle distance calculations for rectangular pads. Skips pairs on the same net or different layers. Closes #44. Co-Authored-By: Claude Opus 4.6 (1M context) --- index.ts | 1 + lib/check-via-to-pad-spacing.ts | 170 +++++++++++++ lib/drc-defaults.ts | 2 + lib/run-all-checks.ts | 2 + tests/lib/check-via-to-pad-spacing.test.ts | 276 +++++++++++++++++++++ 5 files changed, 451 insertions(+) create mode 100644 lib/check-via-to-pad-spacing.ts create mode 100644 tests/lib/check-via-to-pad-spacing.test.ts diff --git a/index.ts b/index.ts index d616603..6ee77a0 100644 --- a/index.ts +++ b/index.ts @@ -21,3 +21,4 @@ export { } from "./lib/run-all-checks" export { checkConnectorAccessibleOrientation } from "./lib/check-connector-accessible-orientation" +export { checkViaToPadSpacing } from "./lib/check-via-to-pad-spacing" diff --git a/lib/check-via-to-pad-spacing.ts b/lib/check-via-to-pad-spacing.ts new file mode 100644 index 0000000..71907b7 --- /dev/null +++ b/lib/check-via-to-pad-spacing.ts @@ -0,0 +1,170 @@ +import type { + AnyCircuitElement, + PcbVia, + PcbSmtPad, + PcbPlatedHole, +} from "circuit-json" +import { getReadableNameForElement } from "@tscircuit/circuit-json-util" +import { + getFullConnectivityMapFromCircuitJson, + type ConnectivityMap, +} from "circuit-json-to-connectivity-map" +import { DEFAULT_VIA_TO_PAD_MARGIN, EPSILON } from "lib/drc-defaults" + +type Pad = PcbSmtPad | PcbPlatedHole + +function getPadId(pad: Pad): string { + if (pad.type === "pcb_smtpad") return pad.pcb_smtpad_id + return pad.pcb_plated_hole_id +} + +function getPadLayers(pad: Pad): string[] { + if (pad.type === "pcb_smtpad") return [pad.layer] + return pad.layers +} + +function sharesLayer(via: PcbVia, pad: Pad): boolean { + const padLayers = getPadLayers(pad) + return via.layers.some((l) => padLayers.includes(l)) +} + +/** + * Compute the edge-to-edge distance between a via (circle) and a pad. + * For rect pads we use closest-point-on-rect to circle-center distance. + * For circle / pill pads we use center-to-center minus radii. + */ +function viaTopadGap(via: PcbVia, pad: Pad): number { + const vr = via.outer_diameter / 2 + + if (pad.type === "pcb_plated_hole") { + if (pad.shape === "circle") { + const d = Math.hypot(via.x - pad.x, via.y - pad.y) + return d - vr - pad.outer_diameter / 2 + } + // oval / pill plated hole + const hw = (pad as any).outer_width / 2 + const hh = (pad as any).outer_height / 2 + return rectToCircleGap(pad.x, pad.y, hw, hh, via.x, via.y, vr) + } + + // pcb_smtpad + if (pad.shape === "circle") { + const d = Math.hypot(via.x - pad.x, via.y - pad.y) + return d - vr - pad.radius + } + + if (pad.shape === "rect" || pad.shape === "rotated_rect") { + const hw = (pad as any).width / 2 + const hh = (pad as any).height / 2 + return rectToCircleGap(pad.x, pad.y, hw, hh, via.x, via.y, vr) + } + + if (pad.shape === "pill" || pad.shape === "rotated_pill") { + // pill is a rounded rect – approximate as rect for clearance + const hw = (pad as any).width / 2 + const hh = (pad as any).height / 2 + return rectToCircleGap(pad.x, pad.y, hw, hh, via.x, via.y, vr) + } + + // polygon – fall back to bounding-box center distance + if (pad.shape === "polygon") { + const pts: { x: number; y: number }[] = (pad as any).points ?? [] + if (pts.length === 0) return Number.POSITIVE_INFINITY + const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length + const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length + const maxR = Math.max(...pts.map((p) => Math.hypot(p.x - cx, p.y - cy))) + const d = Math.hypot(via.x - cx, via.y - cy) + return d - vr - maxR + } + + return Number.POSITIVE_INFINITY +} + +/** Edge-to-edge gap between an axis-aligned rect and a circle */ +function rectToCircleGap( + rx: number, + ry: number, + hw: number, + hh: number, + cx: number, + cy: number, + cr: number, +): number { + // closest point on rect to circle center + const closestX = Math.max(rx - hw, Math.min(cx, rx + hw)) + const closestY = Math.max(ry - hh, Math.min(cy, ry + hh)) + const d = Math.hypot(cx - closestX, cy - closestY) + return d - cr +} + +export interface ViaToPadSpacingError { + type: "pcb_via_clearance_error" + pcb_error_id: string + message: string + error_type: "pcb_via_clearance_error" + pcb_via_ids: string[] + minimum_clearance: number + actual_clearance: number + pcb_center: { x: number; y: number } +} + +export function checkViaToPadSpacing( + circuitJson: AnyCircuitElement[], + { + connMap, + minSpacing = DEFAULT_VIA_TO_PAD_MARGIN, + }: { connMap?: ConnectivityMap; minSpacing?: number } = {}, +): ViaToPadSpacingError[] { + const vias = circuitJson.filter((el) => el.type === "pcb_via") as PcbVia[] + const pads = circuitJson.filter( + (el) => el.type === "pcb_smtpad" || el.type === "pcb_plated_hole", + ) as Pad[] + + if (vias.length === 0 || pads.length === 0) return [] + + connMap ??= getFullConnectivityMapFromCircuitJson(circuitJson) + + const errors: ViaToPadSpacingError[] = [] + const reported = new Set() + + for (const via of vias) { + for (const pad of pads) { + if (!sharesLayer(via, pad)) continue + + const viaId = via.pcb_via_id + const padId = getPadId(pad) + + // Skip if on the same net + if (connMap.areIdsConnected(viaId, padId)) continue + + const gap = viaTopadGap(via, pad) + if (gap + EPSILON >= minSpacing) continue + + const pairId = [viaId, padId].sort().join("_") + if (reported.has(pairId)) continue + reported.add(pairId) + + errors.push({ + type: "pcb_via_clearance_error", + pcb_error_id: `via_too_close_to_pad_${pairId}`, + message: `Via ${getReadableNameForElement( + circuitJson, + viaId, + )} is too close to pad ${getReadableNameForElement( + circuitJson, + padId, + )} (gap: ${gap.toFixed(3)}mm, min: ${minSpacing}mm)`, + error_type: "pcb_via_clearance_error", + pcb_via_ids: [viaId], + minimum_clearance: minSpacing, + actual_clearance: gap, + pcb_center: { + x: (via.x + (pad as any).x) / 2, + y: (via.y + (pad as any).y) / 2, + }, + }) + } + } + + return errors +} diff --git a/lib/drc-defaults.ts b/lib/drc-defaults.ts index cf578e9..72fac3d 100644 --- a/lib/drc-defaults.ts +++ b/lib/drc-defaults.ts @@ -6,4 +6,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..6f1eecf 100644 --- a/lib/run-all-checks.ts +++ b/lib/run-all-checks.ts @@ -15,6 +15,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 { checkViaToPadSpacing } from "./check-via-to-pad-spacing" export async function runAllPlacementChecks(circuitJson: AnyCircuitElement[]) { return [ @@ -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..c46f6c3 --- /dev/null +++ b/tests/lib/check-via-to-pad-spacing.test.ts @@ -0,0 +1,276 @@ +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 an smt rect pad", () => { + const soup: AnyCircuitElement[] = [ + { type: "pcb_trace", pcb_trace_id: "trace1", route: [] }, + { type: "pcb_trace", pcb_trace_id: "trace2", route: [] }, + { + 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", + shape: "rect", + x: 0.5, + y: 0, + width: 0.4, + height: 0.4, + layer: "top", + port_hints: ["1"], + }, + { + type: "pcb_port", + pcb_port_id: "port1", + source_port_id: "source_port1", + pcb_component_id: "comp1", + x: 0.5, + y: 0, + layers: ["top"], + }, + { + type: "source_port", + source_port_id: "source_port1", + source_component_id: "source_comp1", + name: "pin1", + }, + { + type: "pcb_component", + pcb_component_id: "comp1", + source_component_id: "source_comp1", + center: { x: 0.5, y: 0 }, + width: 1, + height: 1, + rotation: 0, + layer: "top", + }, + { + type: "source_component", + source_component_id: "source_comp1", + ftype: "simple_resistor", + name: "R1", + resistance: 1000, + }, + ] as AnyCircuitElement[] + + const errors = checkViaToPadSpacing(soup) + expect(errors.length).toBeGreaterThanOrEqual(1) + expect(errors[0].message).toContain("too close to pad") + }) + + test("no error when via is far from pad", () => { + const soup: AnyCircuitElement[] = [ + { type: "pcb_trace", pcb_trace_id: "trace1", route: [] }, + { + 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", + shape: "rect", + x: 3, + y: 0, + width: 0.4, + height: 0.4, + layer: "top", + port_hints: ["1"], + }, + ] as AnyCircuitElement[] + + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(0) + }) + + test("no error when via and pad are on the same net", () => { + const soup: AnyCircuitElement[] = [ + { type: "pcb_trace", pcb_trace_id: "trace1", route: [] }, + { + 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", + shape: "rect", + x: 0.5, + y: 0, + width: 0.4, + height: 0.4, + layer: "top", + port_hints: ["1"], + }, + { + type: "pcb_port", + pcb_port_id: "port1", + source_port_id: "source_port1", + pcb_component_id: "comp1", + x: 0.5, + y: 0, + layers: ["top"], + }, + { + type: "source_port", + source_port_id: "source_port1", + source_component_id: "source_comp1", + name: "pin1", + }, + { + type: "source_trace", + source_trace_id: "source_trace1", + connected_source_port_ids: ["source_port1"], + connected_source_net_ids: [], + }, + { + type: "pcb_component", + pcb_component_id: "comp1", + source_component_id: "source_comp1", + center: { x: 0.5, y: 0 }, + width: 1, + height: 1, + rotation: 0, + layer: "top", + }, + { + type: "source_component", + source_component_id: "source_comp1", + ftype: "simple_resistor", + name: "R1", + resistance: 1000, + }, + ] as AnyCircuitElement[] + + // Via trace1 connects via1; pad1 connects through port1 -> source_port1 -> source_trace1 -> trace1 + // The connectivity map should show them as connected via the trace + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(0) + }) + + test("no error when via and pad are on different layers", () => { + const soup: AnyCircuitElement[] = [ + { type: "pcb_trace", pcb_trace_id: "trace1", route: [] }, + { + 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"], + }, + { + type: "pcb_smtpad", + pcb_smtpad_id: "pad1", + pcb_component_id: "comp1", + pcb_port_id: "port1", + shape: "rect", + x: 0.5, + y: 0, + width: 0.4, + height: 0.4, + layer: "bottom", + port_hints: ["1"], + }, + ] as AnyCircuitElement[] + + const errors = checkViaToPadSpacing(soup) + expect(errors).toHaveLength(0) + }) + + test("returns error when via is too close to a circle smt pad", () => { + const soup: AnyCircuitElement[] = [ + { type: "pcb_trace", pcb_trace_id: "trace1", route: [] }, + { type: "pcb_trace", pcb_trace_id: "trace2", route: [] }, + { + 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", + shape: "circle", + x: 0.5, + y: 0, + radius: 0.2, + layer: "top", + port_hints: ["1"], + }, + ] as AnyCircuitElement[] + + // gap = 0.5 - 0.3 - 0.2 = 0.0 < 0.2 default + const errors = checkViaToPadSpacing(soup) + expect(errors.length).toBeGreaterThanOrEqual(1) + expect(errors[0].message).toContain("too close to pad") + }) + + test("returns error when via is too close to a plated hole", () => { + const soup: AnyCircuitElement[] = [ + { type: "pcb_trace", pcb_trace_id: "trace1", route: [] }, + { type: "pcb_trace", pcb_trace_id: "trace2", route: [] }, + { + 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_plated_hole", + pcb_plated_hole_id: "ph1", + pcb_component_id: "comp1", + pcb_port_id: "port1", + shape: "circle", + x: 0.5, + y: 0, + hole_diameter: 0.3, + outer_diameter: 0.6, + layers: ["top", "bottom"], + port_hints: ["1"], + }, + ] as AnyCircuitElement[] + + // gap = 0.5 - 0.3 - 0.3 = -0.1 < 0.2 default + const errors = checkViaToPadSpacing(soup) + expect(errors.length).toBeGreaterThanOrEqual(1) + expect(errors[0].message).toContain("too close to pad") + }) +}) From b49112b088e7f9ba3fbfd9981c2fdbc4d98ddb37 Mon Sep 17 00:00:00 2001 From: fengfirewudi Date: Wed, 15 Apr 2026 18:43:48 -0400 Subject: [PATCH 2/3] fix: add source_trace_id to pcb_trace in same-net test fixture The test for "no error when via and pad are on the same net" was failing because the pcb_trace was missing its source_trace_id link, so the connectivity map could not resolve via and pad as connected. --- tests/lib/check-via-to-pad-spacing.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/check-via-to-pad-spacing.test.ts b/tests/lib/check-via-to-pad-spacing.test.ts index c46f6c3..33067d1 100644 --- a/tests/lib/check-via-to-pad-spacing.test.ts +++ b/tests/lib/check-via-to-pad-spacing.test.ts @@ -103,7 +103,7 @@ describe("checkViaToPadSpacing", () => { test("no error when via and pad are on the same net", () => { const soup: AnyCircuitElement[] = [ - { type: "pcb_trace", pcb_trace_id: "trace1", route: [] }, + { type: "pcb_trace", pcb_trace_id: "trace1", source_trace_id: "source_trace1", route: [] }, { type: "pcb_via", pcb_via_id: "via1", From 5338a9bd8866a6a14a81110ca3d1a9c4c2f3602e Mon Sep 17 00:00:00 2001 From: fengfirewudi Date: Thu, 16 Apr 2026 09:17:42 -0400 Subject: [PATCH 3/3] fix: format test file with biome --- tests/lib/check-via-to-pad-spacing.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/lib/check-via-to-pad-spacing.test.ts b/tests/lib/check-via-to-pad-spacing.test.ts index 33067d1..c9cc7bc 100644 --- a/tests/lib/check-via-to-pad-spacing.test.ts +++ b/tests/lib/check-via-to-pad-spacing.test.ts @@ -103,7 +103,12 @@ describe("checkViaToPadSpacing", () => { test("no error when via and pad are on the same net", () => { const soup: AnyCircuitElement[] = [ - { type: "pcb_trace", pcb_trace_id: "trace1", source_trace_id: "source_trace1", route: [] }, + { + type: "pcb_trace", + pcb_trace_id: "trace1", + source_trace_id: "source_trace1", + route: [], + }, { type: "pcb_via", pcb_via_id: "via1",