diff --git a/lib/components/normal-components/Board.ts b/lib/components/normal-components/Board.ts index 22a2d33b7..5aefb0581 100644 --- a/lib/components/normal-components/Board.ts +++ b/lib/components/normal-components/Board.ts @@ -11,6 +11,7 @@ import { boardProps } from "@tscircuit/props" import type { AnyCircuitElement, LayerRef, PcbBoard } from "circuit-json" import { getBoardAvailableLayers } from "lib/utils/getViaSpanLayers" import { type Matrix, compose, translate } from "transformation-matrix" +import type { z } from "zod" import { getDescendantSubcircuitIds } from "../../utils/autorouting/getAncestorSubcircuitIds" import { getBoardCenterFromAnchor } from "../../utils/boards/get-board-center-from-anchor" import { inflateCircuitJson } from "../../utils/circuit-json/inflate-circuit-json" @@ -24,6 +25,7 @@ import { Subcircuit_doInitialRenderIsolatedSubcircuits } from "../primitive-comp import { Subcircuit_getSubcircuitPropHash } from "../primitive-components/Group/Subcircuit_getSubcircuitPropHash" import type { BoardI } from "./BoardI" import { Board_doInitialPcbPlacementDesignRuleChecks } from "./Board_doInitialPcbPlacementDesignRuleChecks" +import { BoardCastellatedHole } from "./board-castellated-hole" const MIN_EFFECTIVE_BORDER_RADIUS_MM = 0.01 const DEFAULT_VIA_PAD_DIAMETER_OVER_HOLE_DIAMETER_MM = 0.15 @@ -105,6 +107,19 @@ export class Board _drcChecksInProgress = false _connectedSchematicPortPairs = new Set() _panelPositionOffset: { x: number; y: number } | null = null + readonly _castellatedHoles: BoardCastellatedHole[] + + constructor(props: z.input) { + super(props) + this._castellatedHoles = BoardCastellatedHole.fromBoardOutline( + this._parsedProps.outline, + ) + for (const castellatedHole of this._castellatedHoles) { + if (castellatedHole.port) this.add(castellatedHole.port) + this.add(castellatedHole) + if (castellatedHole.trace) this.add(castellatedHole.trace) + } + } get isSubcircuit() { return true @@ -661,7 +676,11 @@ export class Board if (shouldRunRoutingChecks) { checksToRun.push( - runAllRoutingChecks(circuitJson) as Promise, + runAllRoutingChecks(circuitJson).then((results) => + results.filter( + (result) => !this._isExpectedCastellatedHoleDrcError(result), + ), + ) as Promise, ) } @@ -669,15 +688,19 @@ export class Board const existingPlacementDiagnostics = db.toArray() checksToRun.push( runAllPlacementChecks(circuitJson).then((results) => - results.filter( - (result) => - !existingPlacementDiagnostics.some( - (existing) => - existing.type === result.type && - "message" in existing && - existing.message === result.message, - ), - ), + results + .filter( + (result) => !this._isExpectedCastellatedHoleDrcError(result), + ) + .filter( + (result) => + !existingPlacementDiagnostics.some( + (existing) => + existing.type === result.type && + "message" in existing && + existing.message === result.message, + ), + ), ) as Promise, ) } @@ -770,8 +793,17 @@ export class Board db.pcb_board.update(this.pcb_board_id, { outline: newOutline, }) + for (const castellatedHole of this._castellatedHoles) { + castellatedHole.syncPositionToBoardOutline() + } } } } } + + _isExpectedCastellatedHoleDrcError(result: AnyCircuitElement): boolean { + return this._castellatedHoles.some((castellatedHole) => + castellatedHole.isExpectedBoardEdgeDrcError(result), + ) + } } diff --git a/lib/components/normal-components/Board_doInitialPcbPlacementDesignRuleChecks.ts b/lib/components/normal-components/Board_doInitialPcbPlacementDesignRuleChecks.ts index 6d089bcc1..def0f6168 100644 --- a/lib/components/normal-components/Board_doInitialPcbPlacementDesignRuleChecks.ts +++ b/lib/components/normal-components/Board_doInitialPcbPlacementDesignRuleChecks.ts @@ -44,7 +44,10 @@ export const Board_doInitialPcbPlacementDesignRuleChecks = (board: Board) => { const placementCheckResults = await runAllPlacementChecks( subcircuitCircuitJson, ) - const newPlacementDiagnostics = placementCheckResults.filter( + const relevantPlacementCheckResults = placementCheckResults.filter( + (result) => !board._isExpectedCastellatedHoleDrcError(result), + ) + const newPlacementDiagnostics = relevantPlacementCheckResults.filter( (result) => !existingPlacementDiagnostics.some( (existing) => @@ -55,7 +58,7 @@ export const Board_doInitialPcbPlacementDesignRuleChecks = (board: Board) => { ) db.insertAll(newPlacementDiagnostics as AnyCircuitElement[]) - board._pcbPlacementDrcErrorCount = placementCheckResults.filter( + board._pcbPlacementDrcErrorCount = relevantPlacementCheckResults.filter( (result) => result.type.endsWith("_error"), ).length } catch (error) { diff --git a/lib/components/normal-components/board-castellated-hole.ts b/lib/components/normal-components/board-castellated-hole.ts new file mode 100644 index 000000000..458bff766 --- /dev/null +++ b/lib/components/normal-components/board-castellated-hole.ts @@ -0,0 +1,168 @@ +import type { BoardOutlinePoint } from "@tscircuit/props" +import { + type AnyCircuitElement, + type PcbPlatedHoleCircle, + distance, +} from "circuit-json" +import { PlatedHole } from "../primitive-components/PlatedHole" +import { Port } from "../primitive-components/Port" +import { Trace } from "../primitive-components/Trace/Trace" +import type { Board } from "./Board" + +const CASTELLATED_HOLE_ENDPOINT_TOLERANCE_MM = 1e-6 + +const getConnectionTargets = ( + connectsTo: BoardOutlinePoint["connectsTo"], +): string[] => { + if (!connectsTo) return [] + return Array.isArray(connectsTo) ? connectsTo : [connectsTo] +} + +export class BoardCastellatedHole extends PlatedHole { + readonly outlinePointIndex: number + readonly holeDiameter: number + readonly padDiameter: number + readonly port: Port | null + readonly trace: Trace | null + + static fromBoardOutline( + outline: BoardOutlinePoint[] | undefined, + ): BoardCastellatedHole[] { + return (outline ?? []).flatMap((outlinePoint, outlinePointIndex) => + outlinePoint.isCastellatedHole + ? [new BoardCastellatedHole(outlinePoint, outlinePointIndex)] + : [], + ) + } + + constructor(outlinePoint: BoardOutlinePoint, outlinePointIndex: number) { + const name = `castellated_hole_${outlinePointIndex + 1}` + const connectionTargets = getConnectionTargets(outlinePoint.connectsTo) + const holeDiameter = distance.parse(outlinePoint.holeDiameter!) + const padDiameter = distance.parse(outlinePoint.padDiameter!) + + super({ + shape: "circle", + holeDiameter, + outerDiameter: padDiameter, + portHints: [name], + }) + + this.outlinePointIndex = outlinePointIndex + this.holeDiameter = holeDiameter + this.padDiameter = padDiameter + this.port = connectionTargets.length > 0 ? new Port({ name }) : null + this.trace = this.port + ? new Trace({ + path: [`port.${name}`, ...connectionTargets], + displayName: `Castellated hole ${outlinePointIndex + 1} connectivity`, + }) + : null + } + + private _getParentBoard(): Board { + if (this.parent?.componentName !== "Board") { + throw new Error("A board castellated hole must be a direct board child") + } + return this.parent as Board + } + + /** + * Returns the castellation center as a point in the right-handed PCB world + * XY frame (+X right, +Y top), in millimeters. The emitted board outline is + * the canonical source, so this point already includes every translation. + */ + private _getPositionFromBoardOutline(): { x: number; y: number } { + const board = this._getParentBoard() + const pcbBoard = board.pcb_board_id + ? board.root?.db.pcb_board.get(board.pcb_board_id) + : null + const outlinePoint = pcbBoard?.outline?.[this.outlinePointIndex] + if (!outlinePoint) { + throw new Error( + `Missing emitted board outline point ${this.outlinePointIndex}`, + ) + } + return outlinePoint + } + + override _getGlobalPcbPositionBeforeLayout(): { x: number; y: number } { + return this._getPositionFromBoardOutline() + } + + override doInitialPcbPrimitiveRender(): void { + if (this.root?.pcbDisabled) return + + const { db } = this.root! + const position = this._getPositionFromBoardOutline() + const pcbPlatedHole = db.pcb_plated_hole.insert({ + shape: "circle", + outer_diameter: this.padDiameter, + hole_diameter: this.holeDiameter, + ...position, + layers: this.getAvailablePcbLayers(), + port_hints: this.getNameAndAliases(), + subcircuit_id: this.getSubcircuit()?.subcircuit_id ?? undefined, + } as Omit) + this.pcb_plated_hole_id = pcbPlatedHole.pcb_plated_hole_id + } + + syncPositionToBoardOutline(): void { + if (!this.pcb_plated_hole_id) return + this._setPositionFromLayout(this._getPositionFromBoardOutline()) + } + + removePcbPrimitiveRender(): void { + const { db } = this.root! + if (this.pcb_plated_hole_id) { + db.pcb_plated_hole.delete(this.pcb_plated_hole_id) + this.pcb_plated_hole_id = null + } + if (this.matchedPort?.pcb_port_id) { + db.pcb_port.delete(this.matchedPort.pcb_port_id) + this.matchedPort.pcb_port_id = null + } + } + + isExpectedBoardEdgeDrcError(result: AnyCircuitElement): boolean { + if (!this.pcb_plated_hole_id) return false + + if (result.type === "pcb_placement_error") { + return ( + result.pcb_placement_error_id === + `copper_too_close_to_board_edge_${this.pcb_plated_hole_id}` + ) + } + + if ( + result.type !== "pcb_trace_error" || + !result.pcb_trace_error_id.startsWith("trace_too_close_to_board_") + ) { + return false + } + + const segmentIndexMatch = result.pcb_trace_error_id.match(/_segment_(\d+)$/) + if (!segmentIndexMatch) return false + + const pcbTrace = this.root?.db.pcb_trace.get(result.pcb_trace_id) + const segmentIndex = Number(segmentIndexMatch[1]) + const segmentEndpoints = [ + pcbTrace?.route[segmentIndex], + pcbTrace?.route[segmentIndex + 1], + ].flatMap((routePoint) => + routePoint && "x" in routePoint && "y" in routePoint + ? [{ x: routePoint.x, y: routePoint.y }] + : [], + ) + const platedHole = this.root?.db.pcb_plated_hole.get( + this.pcb_plated_hole_id, + ) + if (!platedHole) return false + + return segmentEndpoints.some( + (endpoint) => + Math.hypot(endpoint.x - platedHole.x, endpoint.y - platedHole.y) <= + CASTELLATED_HOLE_ENDPOINT_TOLERANCE_MM, + ) + } +} diff --git a/lib/components/primitive-components/Port/Port.ts b/lib/components/primitive-components/Port/Port.ts index 8f2286a2f..d58cd13a1 100644 --- a/lib/components/primitive-components/Port/Port.ts +++ b/lib/components/primitive-components/Port/Port.ts @@ -72,7 +72,7 @@ export class Port extends PrimitiveComponent { } isGroupPort(): boolean { - return this.parent?.componentName === "Group" + return this.parent?.isGroup === true } isComponentPort(): boolean { diff --git a/lib/components/primitive-components/Port/Port_tryRenderGroupPcbPort.ts b/lib/components/primitive-components/Port/Port_tryRenderGroupPcbPort.ts index 21a848976..8f406d458 100644 --- a/lib/components/primitive-components/Port/Port_tryRenderGroupPcbPort.ts +++ b/lib/components/primitive-components/Port/Port_tryRenderGroupPcbPort.ts @@ -1,10 +1,37 @@ import type { Port } from "./Port" +import { areAllPcbPrimitivesOverlapping } from "./areAllPcbPrimitivesOverlapping" +import { getCenterOfPcbPrimitives } from "./getCenterOfPcbPrimitives" export function Port_tryRenderGroupPcbPort(port: Port): boolean { if (port.root?.pcbDisabled) return false if (port.pcb_port_id) return true const { db } = port.root! + const matchedPcbPrimitives = port.matchedComponents.filter( + (component) => component.isPcbPrimitive, + ) + const matchedPrimitiveCenter = + matchedPcbPrimitives.length === 1 + ? matchedPcbPrimitives[0]._getPcbCircuitJsonBounds().center + : matchedPcbPrimitives.length > 1 && + areAllPcbPrimitivesOverlapping(matchedPcbPrimitives) + ? getCenterOfPcbPrimitives(matchedPcbPrimitives) + : null + + if (matchedPrimitiveCenter) { + const pcbPort = db.pcb_port.insert({ + pcb_component_id: undefined as any, + layers: port.getAvailablePcbLayers(), + subcircuit_id: port.getSubcircuit()?.subcircuit_id ?? undefined, + pcb_group_id: port.getGroup()?.pcb_group_id ?? undefined, + ...matchedPrimitiveCenter, + source_port_id: port.source_port_id!, + is_board_pinout: port.parent?.componentName === "Board", + }) + port.pcb_port_id = pcbPort.pcb_port_id + return true + } + const connectedPort = port._getConnectedPortsFromConnectsTo()[0] if (!connectedPort?.pcb_port_id) return false diff --git a/package.json b/package.json index ed732f92e..86fb74511 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@tscircuit/math-utils": "^0.0.36", "@tscircuit/miniflex": "^0.0.4", "@tscircuit/ngspice-spice-engine": "^0.0.20", - "@tscircuit/props": "^0.0.635", + "@tscircuit/props": "^0.0.636", "@tscircuit/schematic-match-adapt": "^0.0.18", "@tscircuit/schematic-trace-solver": "^0.0.159", "@tscircuit/solver-utils": "^0.0.16", @@ -130,7 +130,7 @@ }, "overrides": { "@tscircuit/circuit-json-util": "^0.0.106", - "@tscircuit/props": "^0.0.635", + "@tscircuit/props": "^0.0.636", "circuit-json": "^0.0.476" } } diff --git a/tests/components/normal-components/__snapshots__/board-castellated-holes-panel-layout-pcb.snap.svg b/tests/components/normal-components/__snapshots__/board-castellated-holes-panel-layout-pcb.snap.svg new file mode 100644 index 000000000..1d07170d6 --- /dev/null +++ b/tests/components/normal-components/__snapshots__/board-castellated-holes-panel-layout-pcb.snap.svg @@ -0,0 +1 @@ +B1 CASTELLATIONB2 CASTELLATIONX: -6mmX: 6mm \ No newline at end of file diff --git a/tests/components/normal-components/__snapshots__/board-castellated-holes-pcb.snap.svg b/tests/components/normal-components/__snapshots__/board-castellated-holes-pcb.snap.svg new file mode 100644 index 000000000..97e1044a9 --- /dev/null +++ b/tests/components/normal-components/__snapshots__/board-castellated-holes-pcb.snap.svg @@ -0,0 +1 @@ +CASTELLATED BOARD EDGE HOLES \ No newline at end of file diff --git a/tests/components/normal-components/board-castellated-holes-panel-layout.test.tsx b/tests/components/normal-components/board-castellated-holes-panel-layout.test.tsx new file mode 100644 index 000000000..363b4afce --- /dev/null +++ b/tests/components/normal-components/board-castellated-holes-panel-layout.test.tsx @@ -0,0 +1,67 @@ +import { expect, test } from "bun:test" +import type { BoardOutlinePoint } from "@tscircuit/props" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("panel layout keeps castellated holes aligned with translated board outlines", async () => { + const { circuit } = getTestFixture() + const outline = [ + { x: -5, y: -4 }, + { x: 5, y: -4 }, + { x: 5, y: 4 }, + { x: -5, y: 4 }, + { + x: -5, + y: 0, + isCastellatedHole: true, + holeDiameter: "0.8mm", + padDiameter: "1.2mm", + }, + ] satisfies BoardOutlinePoint[] + + circuit.add( + + + + + + + + , + ) + + await circuit.renderUntilSettled() + + const pcbBoards = circuit.db.pcb_board.list() + const platedHoles = circuit.db.pcb_plated_hole.list() + + expect(pcbBoards).toHaveLength(2) + expect(platedHoles).toHaveLength(2) + + for (const pcbBoard of pcbBoards) { + const platedHole = platedHoles.find( + (hole) => + Math.abs(hole.x - (pcbBoard.center.x - 5)) < 1e-6 && + Math.abs(hole.y - pcbBoard.center.y) < 1e-6, + ) + const castellatedOutlinePoint = pcbBoard.outline?.find( + (point) => + Math.abs(point.x - (pcbBoard.center.x - 5)) < 1e-6 && + Math.abs(point.y - pcbBoard.center.y) < 1e-6, + ) + + expect(platedHole).toMatchObject({ + x: pcbBoard.center.x - 5, + y: pcbBoard.center.y, + hole_diameter: 0.8, + outer_diameter: 1.2, + }) + expect(castellatedOutlinePoint).toEqual({ + x: platedHole!.x, + y: platedHole!.y, + }) + } + + expect(circuit).toMatchPcbSnapshot(import.meta.path, { + showAnchorOffsets: true, + }) +}) diff --git a/tests/components/normal-components/board-castellated-holes.test.tsx b/tests/components/normal-components/board-castellated-holes.test.tsx new file mode 100644 index 000000000..ee3f472e9 --- /dev/null +++ b/tests/components/normal-components/board-castellated-holes.test.tsx @@ -0,0 +1,141 @@ +import { expect, test } from "bun:test" +import type { BoardOutlinePoint } from "@tscircuit/props" +import { getFullConnectivityMapFromCircuitJson } from "circuit-json-to-connectivity-map" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("board outline points render castellated holes with optional connectivity", async () => { + const { circuit } = getTestFixture() + const outline = [ + { x: -6, y: -5 }, + { x: 6, y: -5 }, + { + x: 6, + y: 0, + isCastellatedHole: true, + holeDiameter: "1mm", + padDiameter: "1.5mm", + }, + { x: 6, y: 5 }, + { x: -6, y: 5 }, + { + x: -6, + y: 0, + isCastellatedHole: true, + holeDiameter: "0.8mm", + padDiameter: "1.2mm", + connectsTo: [".U1 > .GND", "net.GND"], + }, + ] satisfies BoardOutlinePoint[] + + circuit.add( + + + + + } + /> + + , + ) + + await circuit.renderUntilSettled() + + const circuitJson = circuit.getCircuitJson() + const pcbBoard = circuit.db.pcb_board.list()[0] + const platedHoles = circuit.db.pcb_plated_hole + .list() + .sort((a, b) => a.x - b.x) + const pcbPorts = circuit.db.pcb_port.list() + const castellatedSourcePort = circuit.db.source_port + .list() + .find((port) => port.name === "castellated_hole_6") + const chipSourcePort = circuit.db.source_port + .list() + .find((port) => port.port_hints?.includes("GND")) + const sourceNet = circuit.db.source_net + .list() + .find((net) => net.name === "GND") + const sourceTrace = circuit.db.source_trace + .list() + .find((trace) => + trace.connected_source_port_ids.includes( + castellatedSourcePort?.source_port_id ?? "", + ), + ) + const pcbPad = circuit.db.pcb_smtpad.list()[0] + const fullConnectivityMap = getFullConnectivityMapFromCircuitJson(circuitJson) + + expect(pcbBoard.outline).toEqual([ + { x: -5, y: -3 }, + { x: 7, y: -3 }, + { x: 7, y: 2 }, + { x: 7, y: 7 }, + { x: -5, y: 7 }, + { x: -5, y: 2 }, + ]) + expect(platedHoles).toHaveLength(2) + expect(platedHoles[0]).toMatchObject({ + x: -5, + y: 2, + hole_diameter: 0.8, + outer_diameter: 1.2, + layers: ["top", "bottom"], + }) + expect(platedHoles[1]).toMatchObject({ + x: 7, + y: 2, + hole_diameter: 1, + outer_diameter: 1.5, + layers: ["top", "bottom"], + }) + expect(platedHoles[0].pcb_port_id).toBeDefined() + expect(platedHoles[1].pcb_port_id).toBeUndefined() + expect(pcbPorts).toHaveLength(2) + expect( + pcbPorts.find( + (port) => port.source_port_id === castellatedSourcePort?.source_port_id, + ), + ).toMatchObject({ + x: -5, + y: 2, + layers: ["top", "bottom"], + is_board_pinout: true, + }) + expect(sourceTrace?.connected_source_port_ids).toEqual( + expect.arrayContaining([ + castellatedSourcePort!.source_port_id, + chipSourcePort!.source_port_id, + ]), + ) + expect(sourceTrace?.connected_source_net_ids).toContain( + sourceNet!.source_net_id, + ) + expect( + fullConnectivityMap.areAllIdsConnected([ + castellatedSourcePort!.source_port_id, + chipSourcePort!.source_port_id, + sourceNet!.source_net_id, + platedHoles[0].pcb_plated_hole_id, + pcbPad.pcb_smtpad_id, + ]), + ).toBe(true) + expect(circuit.db.pcb_placement_error.list()).toHaveLength(0) + expect(circuit.db.pcb_autorouting_error.list()).toHaveLength(0) + expect(circuit.db.pcb_trace_error.list()).toHaveLength(0) + expect(circuit).toMatchPcbSnapshot(import.meta.path) +})