diff --git a/index.ts b/index.ts index cd67a83..7b89f29 100644 --- a/index.ts +++ b/index.ts @@ -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" diff --git a/lib/check-via-to-pad-spacing.ts b/lib/check-via-to-pad-spacing.ts new file mode 100644 index 0000000..0271aec --- /dev/null +++ b/lib/check-via-to-pad-spacing.ts @@ -0,0 +1,84 @@ +import type { + AnyCircuitElement, + PcbVia, + PcbSmtPad, + PcbPlatedHole, + PcbViaClearanceError, +} from "circuit-json" +import { + getReadableNameForElement, + getBoundsOfPcbElements, +} from "@tscircuit/circuit-json-util" +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 +} + +/** + * Compute the edge-to-edge gap between a via and a pad using bounding boxes. + * Uses getBoundsOfPcbElements to handle all pad shapes generically. + */ +function computeGap(via: PcbVia, pad: Pad): number { + const padBounds = getBoundsOfPcbElements([pad as AnyCircuitElement]) + const viaRadius = via.outer_diameter / 2 + + // Distance from via center to nearest point on pad bounding box + const nearestX = Math.max(padBounds.minX, Math.min(via.x, padBounds.maxX)) + const nearestY = Math.max(padBounds.minY, Math.min(via.y, padBounds.maxY)) + const dist = Math.hypot(via.x - nearestX, via.y - nearestY) + + return dist - viaRadius +} + +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 +} diff --git a/lib/drc-defaults.ts b/lib/drc-defaults.ts index cf578e9..959be96 100644 --- a/lib/drc-defaults.ts +++ b/lib/drc-defaults.ts @@ -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 diff --git a/lib/run-all-checks.ts b/lib/run-all-checks.ts index b73bd1f..711a06e 100644 --- a/lib/run-all-checks.ts +++ b/lib/run-all-checks.ts @@ -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" @@ -32,6 +33,7 @@ export async function runAllRoutingChecks(circuitJson: AnyCircuitElement[]) { ...checkEachPcbTraceNonOverlapping(circuitJson), ...checkSameNetViaSpacing(circuitJson), ...checkDifferentNetViaSpacing(circuitJson), + ...checkViaToPadSpacing(circuitJson), // ...checkTracesAreContiguous(circuitJson), ...checkPcbTracesOutOfBoard(circuitJson), ] diff --git a/tests/assets/via-too-close-to-pad.json b/tests/assets/via-too-close-to-pad.json new file mode 100644 index 0000000..a88bec7 --- /dev/null +++ b/tests/assets/via-too-close-to-pad.json @@ -0,0 +1,170 @@ +[ + { + "type": "pcb_board", + "pcb_board_id": "board1", + "center": { "x": 0, "y": 0 }, + "width": 10, + "height": 8 + }, + { + "type": "source_component", + "source_component_id": "sc1", + "ftype": "simple_resistor", + "name": "R1" + }, + { + "type": "source_port", + "source_port_id": "sp1", + "source_component_id": "sc1", + "name": "pin1" + }, + { + "type": "source_port", + "source_port_id": "sp2", + "source_component_id": "sc1", + "name": "pin2" + }, + { + "type": "pcb_component", + "pcb_component_id": "pc1", + "source_component_id": "sc1", + "center": { "x": -2, "y": 0 }, + "width": 2.4, + "height": 1.2, + "rotation": 0, + "layer": "top" + }, + { + "type": "pcb_smtpad", + "pcb_smtpad_id": "pad1", + "pcb_component_id": "pc1", + "pcb_port_id": "pp1", + "shape": "rect", + "x": -2.8, + "y": 0, + "width": 1.0, + "height": 0.8, + "layer": "top", + "port_hints": ["pin1"] + }, + { + "type": "pcb_smtpad", + "pcb_smtpad_id": "pad2", + "pcb_component_id": "pc1", + "pcb_port_id": "pp2", + "shape": "rect", + "x": -1.2, + "y": 0, + "width": 1.0, + "height": 0.8, + "layer": "top", + "port_hints": ["pin2"] + }, + { + "type": "pcb_port", + "pcb_port_id": "pp1", + "pcb_component_id": "pc1", + "source_port_id": "sp1", + "x": -2.8, + "y": 0, + "layers": ["top"] + }, + { + "type": "pcb_port", + "pcb_port_id": "pp2", + "pcb_component_id": "pc1", + "source_port_id": "sp2", + "x": -1.2, + "y": 0, + "layers": ["top"] + }, + { + "type": "source_trace", + "source_trace_id": "st1", + "connected_source_port_ids": ["sp2"] + }, + { + "type": "pcb_trace", + "pcb_trace_id": "trace1", + "source_trace_id": "st1", + "route": [ + { "route_type": "wire", "x": -1.2, "y": 0, "width": 0.15, "layer": "top" }, + { "route_type": "wire", "x": 0, "y": 0, "width": 0.15, "layer": "top" }, + { "route_type": "via", "x": 0, "y": 0, "to_layer": "bottom", "from_layer": "top" }, + { "route_type": "wire", "x": 0, "y": 0, "width": 0.15, "layer": "bottom" }, + { "route_type": "wire", "x": 2, "y": 0, "width": 0.15, "layer": "bottom" } + ] + }, + { + "type": "pcb_via", + "pcb_via_id": "via1", + "x": -0.5, + "y": 0, + "hole_diameter": 0.3, + "outer_diameter": 0.6, + "layers": ["top", "bottom"] + }, + { + "type": "source_component", + "source_component_id": "sc2", + "ftype": "simple_capacitor", + "name": "C1" + }, + { + "type": "source_port", + "source_port_id": "sp3", + "source_component_id": "sc2", + "name": "pin1" + }, + { + "type": "source_port", + "source_port_id": "sp4", + "source_component_id": "sc2", + "name": "pin2" + }, + { + "type": "pcb_component", + "pcb_component_id": "pc2", + "source_component_id": "sc2", + "center": { "x": 2, "y": -2 }, + "width": 2.0, + "height": 1.0, + "rotation": 0, + "layer": "top" + }, + { + "type": "pcb_smtpad", + "pcb_smtpad_id": "pad3", + "pcb_component_id": "pc2", + "pcb_port_id": "pp3", + "shape": "rect", + "x": 1.5, + "y": -2, + "width": 0.8, + "height": 0.6, + "layer": "top", + "port_hints": ["pin1"] + }, + { + "type": "pcb_smtpad", + "pcb_smtpad_id": "pad4", + "pcb_component_id": "pc2", + "pcb_port_id": "pp4", + "shape": "rect", + "x": 2.5, + "y": -2, + "width": 0.8, + "height": 0.6, + "layer": "top", + "port_hints": ["pin2"] + }, + { + "type": "pcb_via", + "pcb_via_id": "via2", + "x": 1.5, + "y": -1.2, + "hole_diameter": 0.3, + "outer_diameter": 0.6, + "layers": ["top", "bottom"] + } +] diff --git a/tests/lib/__snapshots__/check-via-to-pad-spacing.snap.svg b/tests/lib/__snapshots__/check-via-to-pad-spacing.snap.svg new file mode 100644 index 0000000..49031a0 --- /dev/null +++ b/tests/lib/__snapshots__/check-via-to-pad-spacing.snap.svg @@ -0,0 +1 @@ + \ No newline at end of file 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..a9f08c2 --- /dev/null +++ b/tests/lib/check-via-to-pad-spacing.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { checkViaToPadSpacing } from "lib/check-via-to-pad-spacing" +import type { AnyCircuitElement } from "circuit-json" +import circuitJson from "tests/assets/via-too-close-to-pad.json" + +test("check-via-to-pad-spacing detects violations in realistic circuit", async () => { + const elements = circuitJson as AnyCircuitElement[] + const errors = checkViaToPadSpacing(elements) + + expect(errors.length).toBeGreaterThan(0) + expect(errors[0].type).toBe("pcb_via_clearance_error") + expect(errors[0].message).toContain("too close to pad") + + const svg = convertCircuitJsonToPcbSvg([...elements, ...errors], { + shouldDrawErrors: true, + }) + await expect(svg).toMatchSvgSnapshot(import.meta.path) +})