diff --git a/lib/check-courtyard-overlap/checkCourtyardOverlap.ts b/lib/check-courtyard-overlap/checkCourtyardOverlap.ts new file mode 100644 index 0000000..5fff3d8 --- /dev/null +++ b/lib/check-courtyard-overlap/checkCourtyardOverlap.ts @@ -0,0 +1,123 @@ +import { + areBoundsOverlappingPolygon, + getBoundsFromPoints, +} from "@tscircuit/math-utils" +import type { + AnyCircuitElement, + PcbCourtyardOverlapError, + PcbCourtyardRect, + PcbCourtyardCircle, + PcbCourtyardOutline, +} from "circuit-json" + +type CourtyardElement = + | PcbCourtyardRect + | PcbCourtyardCircle + | PcbCourtyardOutline + +function getCourtyardPolygon(el: CourtyardElement): { x: number; y: number }[] { + if (el.type === "pcb_courtyard_rect") { + const hw = el.width / 2 + const hh = el.height / 2 + return [ + { x: el.center.x - hw, y: el.center.y - hh }, + { x: el.center.x + hw, y: el.center.y - hh }, + { x: el.center.x + hw, y: el.center.y + hh }, + { x: el.center.x - hw, y: el.center.y + hh }, + ] + } + if (el.type === "pcb_courtyard_circle") { + const N = 32 + return Array.from({ length: N }, (_, i) => { + const a = (2 * Math.PI * i) / N + return { + x: el.center.x + el.radius * Math.cos(a), + y: el.center.y + el.radius * Math.sin(a), + } + }) + } + return el.outline +} + +function getComponentName( + circuitJson: AnyCircuitElement[], + pcbComponentId: string, +): string { + const pcbComponent = circuitJson.find( + (el) => + el.type === "pcb_component" && el.pcb_component_id === pcbComponentId, + ) + if (pcbComponent?.type !== "pcb_component") return pcbComponentId + const sourceComponent = circuitJson.find( + (el) => + el.type === "source_component" && + el.source_component_id === pcbComponent.source_component_id, + ) + if (sourceComponent?.type === "source_component" && sourceComponent.name) { + return sourceComponent.name + } + return pcbComponentId +} + +/** + * Check for overlapping PCB component courtyards. + * Returns one error per pair of components whose courtyard elements overlap. + */ +export function checkCourtyardOverlap( + circuitJson: AnyCircuitElement[], +): PcbCourtyardOverlapError[] { + const courtyards = circuitJson.filter( + (el): el is CourtyardElement => + el.type === "pcb_courtyard_rect" || + el.type === "pcb_courtyard_circle" || + el.type === "pcb_courtyard_outline", + ) + + // Group by component + const byComponent = new Map() + for (const el of courtyards) { + const id = el.pcb_component_id + if (!byComponent.has(id)) byComponent.set(id, []) + byComponent.get(id)!.push(el) + } + + const componentIds = Array.from(byComponent.keys()) + const errors: PcbCourtyardOverlapError[] = [] + + for (let i = 0; i < componentIds.length; i++) { + for (let j = i + 1; j < componentIds.length; j++) { + const idA = componentIds[i] + const idB = componentIds[j] + + let overlapping = false + outer: for (const a of byComponent.get(idA)!) { + for (const b of byComponent.get(idB)!) { + const polyA = getCourtyardPolygon(a) + const polyB = getCourtyardPolygon(b) + const boundsA = getBoundsFromPoints(polyA) + const boundsB = getBoundsFromPoints(polyB) + if (!boundsA || !boundsB) continue + if ( + areBoundsOverlappingPolygon(boundsA, polyB) || + areBoundsOverlappingPolygon(boundsB, polyA) + ) { + overlapping = true + break outer + } + } + } + + if (overlapping) { + errors.push({ + type: "pcb_courtyard_overlap_error", + pcb_error_id: `pcb_courtyard_overlap_${idA}_${idB}`, + error_type: "pcb_courtyard_overlap_error", + message: `Courtyard of ${getComponentName(circuitJson, idA)} overlaps with courtyard of ${getComponentName(circuitJson, idB)}`, + pcb_component_ids: [idA, idB], + }) + } + } + } + + return errors +} diff --git a/lib/run-all-checks.ts b/lib/run-all-checks.ts index a1d0a14..325733f 100644 --- a/lib/run-all-checks.ts +++ b/lib/run-all-checks.ts @@ -6,6 +6,7 @@ import { checkEachPcbTraceNonOverlapping } from "./check-each-pcb-trace-non-over import { checkPcbComponentsOutOfBoard } from "./check-pcb-components-out-of-board/checkPcbComponentsOutOfBoard" import { checkViasOffBoard } from "./check-pcb-components-out-of-board/checkViasOffBoard" import { checkPcbComponentOverlap } from "./check-pcb-components-overlap/checkPcbComponentOverlap" +import { checkCourtyardOverlap } from "./check-courtyard-overlap/checkCourtyardOverlap" import { checkConnectorAccessibleOrientation } from "./check-connector-accessible-orientation" import { checkPinMustBeConnected } from "./check-pin-must-be-connected" import { checkNoGroundPinDefined } from "./check-no-ground-pin-defined" @@ -20,6 +21,7 @@ export async function runAllPlacementChecks(circuitJson: AnyCircuitElement[]) { ...checkViasOffBoard(circuitJson), ...checkPcbComponentsOutOfBoard(circuitJson), ...checkPcbComponentOverlap(circuitJson), + ...checkCourtyardOverlap(circuitJson), ...checkConnectorAccessibleOrientation(circuitJson), ] } diff --git a/package.json b/package.json index 98411b1..65802e3 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,15 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.0", - "@tscircuit/circuit-json-util": "^0.0.83", + "@tscircuit/circuit-json-util": "^0.0.90", "@tscircuit/log-soup": "^1.0.2", "@types/bun": "^1.2.8", "@types/debug": "^4.1.12", "bun-match-svg": "^0.0.11", - "circuit-to-svg": "^0.0.333", - "circuit-json": "^0.0.395", + "circuit-to-svg": "^0.0.334", + "circuit-json": "^0.0.400", "debug": "^4.3.5", - "tscircuit": "^0.0.1439", + "tscircuit": "^0.0.1487", "zod": "^3.23.8", "tsup": "^8.2.3" }, diff --git a/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-circle-overlap.snap.svg b/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-circle-overlap.snap.svg new file mode 100644 index 0000000..0c2663f --- /dev/null +++ b/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-circle-overlap.snap.svg @@ -0,0 +1 @@ +Courtyard of U1 overlaps with courtyard of U2 \ No newline at end of file diff --git a/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-mixed-overlap.snap.svg b/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-mixed-overlap.snap.svg new file mode 100644 index 0000000..5e7a753 --- /dev/null +++ b/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-mixed-overlap.snap.svg @@ -0,0 +1 @@ +Courtyard of U1 overlaps with courtyard of C1Courtyard of U1 overlaps with courtyard of R1 \ No newline at end of file diff --git a/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-outline-overlap-no-pad-overlap.snap.svg b/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-outline-overlap-no-pad-overlap.snap.svg new file mode 100644 index 0000000..e6acfbd --- /dev/null +++ b/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-outline-overlap-no-pad-overlap.snap.svg @@ -0,0 +1 @@ +Courtyard of U1 overlaps with courtyard of U2 \ No newline at end of file diff --git a/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-outline-polygon-overlap.snap.svg b/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-outline-polygon-overlap.snap.svg new file mode 100644 index 0000000..126749d --- /dev/null +++ b/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-outline-polygon-overlap.snap.svg @@ -0,0 +1 @@ +Courtyard of U1 overlaps with courtyard of U2 \ No newline at end of file diff --git a/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-rect-overlap.snap.svg b/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-rect-overlap.snap.svg new file mode 100644 index 0000000..6c7420a --- /dev/null +++ b/tests/lib/check-pcb-component-overlap/__snapshots__/courtyard-rect-overlap.snap.svg @@ -0,0 +1 @@ +Courtyard of U1 overlaps with courtyard of U2 \ No newline at end of file diff --git a/tests/lib/check-pcb-component-overlap/courtyard-circle-overlap.test.tsx b/tests/lib/check-pcb-component-overlap/courtyard-circle-overlap.test.tsx new file mode 100644 index 0000000..1b887ee --- /dev/null +++ b/tests/lib/check-pcb-component-overlap/courtyard-circle-overlap.test.tsx @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { Circuit } from "tscircuit" +import { checkCourtyardOverlap } from "lib/check-courtyard-overlap/checkCourtyardOverlap" + +/** + * Three chips with circular courtyards (radius 2 mm). + * + * U1 at x=0: circle spans x ∈ [-2, 2] + * U2 at x=3: circle spans x ∈ [1, 5] → overlaps U1 + * U3 at x=10: circle spans x ∈ [8, 12] → no overlap + * + * Expected: 1 error (U1–U2), U3 is clear. + */ +const ChipWithCourtyardCircle = (props: { name: string; pcbX?: number }) => ( + + + + + + } + /> +) + +test("courtyard circles – U1 and U2 overlap, U3 is clear (3 components)", async () => { + const circuit = new Circuit() + + circuit.add( + + + + + , + ) + + await circuit.renderUntilSettled() + const circuitJson = circuit.getCircuitJson() + + expect( + circuitJson.filter((el) => el.type === "pcb_courtyard_circle"), + ).toHaveLength(3) + + const errors = checkCourtyardOverlap(circuitJson) + expect(errors).toHaveLength(1) + expect(errors[0].type).toBe("pcb_courtyard_overlap_error") + expect(errors[0].pcb_component_ids).toHaveLength(2) + + expect( + convertCircuitJsonToPcbSvg([...circuitJson, ...errors], { + shouldDrawErrors: true, + showCourtyards: true, + }), + ).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/lib/check-pcb-component-overlap/courtyard-mixed-overlap.test.tsx b/tests/lib/check-pcb-component-overlap/courtyard-mixed-overlap.test.tsx new file mode 100644 index 0000000..5bbf528 --- /dev/null +++ b/tests/lib/check-pcb-component-overlap/courtyard-mixed-overlap.test.tsx @@ -0,0 +1,146 @@ +import { expect, test } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { Circuit } from "tscircuit" +import { checkCourtyardOverlap } from "lib/check-courtyard-overlap/checkCourtyardOverlap" + +/** + * Three components with different courtyard types, all overlapping the center + * component but not each other. + * + * U1 (rect 4x4mm) at (0, 0): spans x∈[-2,2], y∈[-2,2] + * U2 (circle r=2mm) at (3, 3): spans x∈[1,5], y∈[1,5] → overlaps U1 top-right corner + * U3 (outline 4x4mm) at (3, -3): spans x∈[1,5], y∈[-5,-1] → overlaps U1 bottom-right corner + * + * Expected: 2 errors (U1–U2, U1–U3). U2 and U3 do not overlap each other. + */ + +const SQUARE_OUTLINE = [ + { x: -2, y: -2 }, + { x: 2, y: -2 }, + { x: 2, y: 2 }, + { x: -2, y: 2 }, + { x: -2, y: -2 }, +] + +test("mixed courtyard types – rect/circle/outline, 2 overlaps with center component", async () => { + const circuit = new Circuit() + + circuit.add( + + {/* U1: rect courtyard in the middle */} + + + + + + } + /> + {/* U2: circle courtyard overlapping U1 top-right corner */} + + + + + + } + /> + {/* U3: outline courtyard overlapping U1 bottom-right corner */} + + + + + + } + /> + , + ) + + await circuit.renderUntilSettled() + const circuitJson = circuit.getCircuitJson() + + const errors = checkCourtyardOverlap(circuitJson) + + expect(errors).toHaveLength(2) + expect(errors.every((e) => e.type === "pcb_courtyard_overlap_error")).toBe( + true, + ) + + // U1 overlaps both U2 and U3 + const u1Source = circuitJson.find( + (el) => el.type === "source_component" && (el as any).name === "U1", + ) as { source_component_id: string } | undefined + const u1Comp = circuitJson.find( + (el) => + el.type === "pcb_component" && + (el as any).source_component_id === u1Source?.source_component_id, + ) as { pcb_component_id: string } | undefined + + expect( + errors.every((e) => e.pcb_component_ids.includes(u1Comp!.pcb_component_id)), + ).toBe(true) + + expect( + convertCircuitJsonToPcbSvg([...circuitJson, ...errors], { + shouldDrawErrors: true, + showCourtyards: true, + }), + ).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/lib/check-pcb-component-overlap/courtyard-outline-overlap-no-pad-overlap.test.tsx b/tests/lib/check-pcb-component-overlap/courtyard-outline-overlap-no-pad-overlap.test.tsx new file mode 100644 index 0000000..3871380 --- /dev/null +++ b/tests/lib/check-pcb-component-overlap/courtyard-outline-overlap-no-pad-overlap.test.tsx @@ -0,0 +1,101 @@ +import { expect, test } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { Circuit } from "tscircuit" +import { checkPcbComponentOverlap } from "lib/check-pcb-components-overlap/checkPcbComponentOverlap" +import { checkCourtyardOverlap } from "lib/check-courtyard-overlap/checkCourtyardOverlap" + +/** + * Two chips whose courtyard outlines overlap but whose pads are far enough + * apart that they do NOT physically overlap. + * + * Layout (top view, all values in mm): + * + * Chip1 centred at x=0 Chip2 centred at x=3 + * courtyard: x ∈ [-2, 2] courtyard: x ∈ [1, 5] + * ↑ overlap zone x ∈ [1, 2] + * + * U1 pads at absolute (-0.8, 0) and (0.8, 0), 0.8×0.8 mm + * U2 pads at absolute (2.2, 0) and (3.8, 0), 0.8×0.8 mm + * Closest pads: U1-pin2 @ x=1.2 vs U2-pin1 @ x=1.8 → 0.6 mm gap, no overlap + */ + +const ChipWithCourtyard = (props: { name: string; pcbX?: number }) => ( + + {/* Two SMT pads placed symmetrically at ±0.8 mm from the component origin */} + + + {/* Courtyard outline: 4 mm wide × 2 mm tall rectangle */} + + + } + /> +) + +test("courtyard outlines overlap but pads do not – no pad overlap error", async () => { + const circuit = new Circuit() + + circuit.add( + + {/* Chip1 at x=0: courtyard x ∈ [-2, 2] */} + + {/* Chip2 at x=3.5: courtyard x ∈ [1.5, 5.5] → overlaps with Chip1 in [1, 2] */} + + , + ) + + await circuit.renderUntilSettled() + + const circuitJson = circuit.getCircuitJson() + + // Verify courtyard elements were emitted + const courtyardOutlines = circuitJson.filter( + (el) => el.type === "pcb_courtyard_outline", + ) + expect(courtyardOutlines.length).toBe(2) + + // The pad-overlap check should report NO errors because pads are 2 mm apart + const padErrors = checkPcbComponentOverlap(circuitJson) + expect(padErrors).toHaveLength(0) + + // The courtyard overlap check SHOULD detect an error + const courtyardErrors = checkCourtyardOverlap(circuitJson) + expect(courtyardErrors).toHaveLength(1) + expect(courtyardErrors[0].type).toBe("pcb_courtyard_overlap_error") + expect(courtyardErrors[0].pcb_component_ids).toHaveLength(2) + + // Visual snapshot with courtyard errors injected + const allErrors = [...courtyardErrors] + expect( + convertCircuitJsonToPcbSvg([...circuitJson, ...allErrors], { + shouldDrawErrors: true, + showCourtyards: true, + }), + ).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/lib/check-pcb-component-overlap/courtyard-outline-polygon-overlap.test.tsx b/tests/lib/check-pcb-component-overlap/courtyard-outline-polygon-overlap.test.tsx new file mode 100644 index 0000000..a4ffb1c --- /dev/null +++ b/tests/lib/check-pcb-component-overlap/courtyard-outline-polygon-overlap.test.tsx @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { Circuit } from "tscircuit" +import { checkCourtyardOverlap } from "lib/check-courtyard-overlap/checkCourtyardOverlap" + +/** + * Three chips with a hexagonal courtyardoutline (circumradius 2 mm). + * + * U1 at x=0: hex spans x ∈ [-2, 2] + * U2 at x=3: hex spans x ∈ [1, 5] → overlaps U1 in x ∈ [1, 2] + * U3 at x=10: hex spans x ∈ [8, 12] → no overlap + * + * Expected: 1 error (U1–U2), U3 is clear. + */ + +const HEX_R = 2 // mm – circumradius +const HEX_VERTS = Array.from({ length: 6 }, (_, i) => { + const a = (Math.PI / 3) * i + return { + x: +(HEX_R * Math.cos(a)).toFixed(4), + y: +(HEX_R * Math.sin(a)).toFixed(4), + } +}) +const HEX_OUTLINE = [...HEX_VERTS, HEX_VERTS[0]] + +const ChipWithHexOutline = (props: { name: string; pcbX?: number }) => ( + + + + + + } + /> +) + +test("courtyard outline (hexagon) – U1 and U2 overlap, U3 is clear (3 components)", async () => { + const circuit = new Circuit() + + circuit.add( + + + + + , + ) + + await circuit.renderUntilSettled() + const circuitJson = circuit.getCircuitJson() + + expect( + circuitJson.filter((el) => el.type === "pcb_courtyard_outline"), + ).toHaveLength(3) + + const errors = checkCourtyardOverlap(circuitJson) + expect(errors).toHaveLength(1) + expect(errors[0].type).toBe("pcb_courtyard_overlap_error") + expect(errors[0].pcb_component_ids).toHaveLength(2) + + expect( + convertCircuitJsonToPcbSvg([...circuitJson, ...errors], { + shouldDrawErrors: true, + showCourtyards: true, + }), + ).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/lib/check-pcb-component-overlap/courtyard-rect-overlap.test.tsx b/tests/lib/check-pcb-component-overlap/courtyard-rect-overlap.test.tsx new file mode 100644 index 0000000..62b50a9 --- /dev/null +++ b/tests/lib/check-pcb-component-overlap/courtyard-rect-overlap.test.tsx @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test" +import { convertCircuitJsonToPcbSvg } from "circuit-to-svg" +import { Circuit } from "tscircuit" +import { checkCourtyardOverlap } from "lib/check-courtyard-overlap/checkCourtyardOverlap" + +/** + * U1 at x=0: courtyard x ∈ [-2, 2] + * U2 at x=3: courtyard x ∈ [1, 5] → overlaps U1 in x ∈ [1, 2] + * U3 at x=10: courtyard x ∈ [8, 12] → no overlap + * + * Expected: 1 error (U1–U2), U3 is clear. + */ + +const ChipWithCourtyardRect = (props: { name: string; pcbX?: number }) => ( + + + + + + } + /> +) + +test("courtyard rects – U1 and U2 overlap, U3 is clear (3 components)", async () => { + const circuit = new Circuit() + + circuit.add( + + + + + , + ) + + await circuit.renderUntilSettled() + const circuitJson = circuit.getCircuitJson() + + expect( + circuitJson.filter((el) => el.type === "pcb_courtyard_rect"), + ).toHaveLength(3) + + const errors = checkCourtyardOverlap(circuitJson) + expect(errors).toHaveLength(1) + expect(errors[0].type).toBe("pcb_courtyard_overlap_error") + expect(errors[0].pcb_component_ids).toHaveLength(2) + + expect( + convertCircuitJsonToPcbSvg([...circuitJson, ...errors], { + shouldDrawErrors: true, + showCourtyards: true, + }), + ).toMatchSvgSnapshot(import.meta.path) +})