imrishabh18/corne-keyboard
The code defines and renders two surface-mount chip components with SMT pads, silkscreen outlines, and 3D CAD models (OBJ and STEP) for PCB assembly.
- Version
- 2.0.21
- License
- unset
- Stars
- 1
vendor/checks/upstream.patch
diff --git a/lib/check-copper-to-board-edge-clearance.ts b/lib/check-copper-to-board-edge-clearance.ts
index c552c50..231ca3b 100644
--- a/lib/check-copper-to-board-edge-clearance.ts
+++ b/lib/check-copper-to-board-edge-clearance.ts
@@ -34,21 +34,26 @@ const pointsToPolygon = (
return new Flatten.Polygon(points.map(({ x, y }) => new Flatten.Point(x, y)))
}
-const brepRingToPolygon = (
+export const brepRingToPolygon = (
vertices: Array<{ x: number; y: number; bulge?: number }>,
): Flatten.Polygon | null => {
- const ring = vertices.filter((vertex, index) => {
- const previous = vertices[index - 1]
- return (
+ // Flatten rejects a line whose endpoints compare equal under its own
+ // tolerance. Use that same predicate before constructing polygon edges.
+ const ring: typeof vertices = []
+ for (const vertex of vertices) {
+ const previous = ring.at(-1)
+ if (
!previous ||
- Math.abs(previous.x - vertex.x) > GEOMETRY_EPSILON ||
- Math.abs(previous.y - vertex.y) > GEOMETRY_EPSILON
- )
- })
+ !new Flatten.Point(previous.x, previous.y).equalTo(
+ new Flatten.Point(vertex.x, vertex.y),
+ )
+ ) ring.push(vertex)
+ }
if (
ring.length > 1 &&
- Math.abs(ring[0].x - ring.at(-1)!.x) <= GEOMETRY_EPSILON &&
- Math.abs(ring[0].y - ring.at(-1)!.y) <= GEOMETRY_EPSILON
+ new Flatten.Point(ring[0].x, ring[0].y).equalTo(
+ new Flatten.Point(ring.at(-1)!.x, ring.at(-1)!.y),
+ )
) {
ring.pop()
}
@@ -265,7 +270,7 @@ const pill = ({
}
}
-const getSmtPadGeometry = (pad: PcbSmtPad): CopperGeometry | null => {
+export const getSmtPadGeometry = (pad: PcbSmtPad): CopperGeometry | null => {
switch (pad.shape) {
case "circle":
return {
@@ -315,7 +320,7 @@ const getSmtPadGeometry = (pad: PcbSmtPad): CopperGeometry | null => {
}
}
-const getPlatedHoleGeometry = (
+export const getPlatedHoleGeometry = (
platedHole: PcbPlatedHole,
componentCcwRotationDegrees: number,
): CopperGeometry | null => {
diff --git a/lib/check-each-pcb-port-connected-to-pcb-trace.ts b/lib/check-each-pcb-port-connected-to-pcb-trace.ts
index 7ac0cd0..a7c2d6b 100644
--- a/lib/check-each-pcb-port-connected-to-pcb-trace.ts
+++ b/lib/check-each-pcb-port-connected-to-pcb-trace.ts
@@ -10,6 +10,7 @@ import {
getFullConnectivityMapFromCircuitJson,
PcbConnectivityMap,
} from "circuit-json-to-connectivity-map"
+import { CopperPourConnectivity } from "./util/copper-pour-connectivity"
import { getReadableNameForPort } from "./util/get-readable-names"
function checkEachPcbPortConnectedToPcbTraces(
@@ -32,6 +33,12 @@ function checkEachPcbPortConnectedToPcbTraces(
// Generate the connectivity map from the circuit
const connectivityMap = getFullConnectivityMapFromCircuitJson(circuitJson)
const pcbConnectivityMap = new PcbConnectivityMap(circuitJson)
+ let pourConnectivity: CopperPourConnectivity | undefined
+ const getPourConnectivity = () =>
+ (pourConnectivity ??= new CopperPourConnectivity(
+ circuitJson,
+ connectivityMap,
+ ))
// Create a map from source_port_id to pcb_port for quick lookup
const sourcePortToPcbPort = new Map<string, PcbPort>()
@@ -58,7 +65,10 @@ function checkEachPcbPortConnectedToPcbTraces(
pcbPort.pcb_port_id,
)
- if (connectedPcbTraces.length === 0) {
+ if (
+ connectedPcbTraces.length === 0 &&
+ !getPourConnectivity().portConnectedToPourNet(pcbPort.pcb_port_id)
+ ) {
const connectedNetNames = sourceTrace.connected_source_net_ids
.map((sourceNetId) => sourceNetNameById.get(sourceNetId))
.filter((name): name is string => Boolean(name))
@@ -121,7 +131,12 @@ function checkEachPcbPortConnectedToPcbTraces(
),
)
- if (pcbTraceIds.length === 0) {
+ if (
+ pcbTraceIds.length === 0 &&
+ !getPourConnectivity().portsConnectedThroughPour(
+ pcbPortsInTrace.map((p) => p.pcb_port_id),
+ )
+ ) {
// Check if this is a trivial case (only 2 ports on same component)
const uniqueComponentIds = new Set(
pcbPortsInTrace.map((p) => p.pcb_component_id),
diff --git a/lib/check-traces-are-contiguous/check-traces-are-contiguous.ts b/lib/check-traces-are-contiguous/check-traces-are-contiguous.ts
index 62534d0..632f54a 100644
--- a/lib/check-traces-are-contiguous/check-traces-are-contiguous.ts
+++ b/lib/check-traces-are-contiguous/check-traces-are-contiguous.ts
@@ -21,6 +21,7 @@ import {
PcbConnectivityMap,
} from "circuit-json-to-connectivity-map"
import { getLayersOfPcbElement } from "../util/getLayersOfPcbElement"
+import { CopperPourConnectivity } from "../util/copper-pour-connectivity"
import { endpointTouchesVia, getViaContactIndex } from "./via-contact-index"
type PcbPortId = PcbPort["pcb_port_id"]
@@ -318,6 +319,12 @@ function checkTracesAreContiguous(
)
return viaContactIndex
}
+ let pourConnectivity: CopperPourConnectivity | undefined
+ const getPourConnectivity = () =>
+ (pourConnectivity ??= new CopperPourConnectivity(
+ circuitJson,
+ getFullConnectivityMap(),
+ ))
const checkedSourceTraceIds = new Set<string>()
for (const pad of pcbSmtPads) {
@@ -491,7 +498,14 @@ function checkTracesAreContiguous(
.get(candidateTrace.pcb_trace_id)
?.has(port.pcb_port_id),
)
- if (isConnectedByRoutedSourceTrace) continue
+ if (
+ isConnectedByRoutedSourceTrace ||
+ getPourConnectivity().traceConnectedToPortThroughPour(
+ trace.pcb_trace_id,
+ port.pcb_port_id,
+ )
+ )
+ continue
const isFirstPointConnected = pads.some((pad) =>
routePointTouchesPad(firstPoint, pad),
@@ -572,6 +586,11 @@ function checkTracesAreContiguous(
const firstIsConnected =
firstConnectsToAnyPad ||
firstConnectsToLogicallyConnectedTraceCopper ||
+ getPourConnectivity().endpointTouchesConnectedPour(
+ firstPoint,
+ trace.pcb_trace_id,
+ firstEndpointTraceCopperWidth ?? 0,
+ ) ||
(firstEndpointTraceCopperWidth !== undefined &&
endpointTouchesVia({
point: firstPoint,
@@ -583,6 +602,11 @@ function checkTracesAreContiguous(
const lastIsConnected =
lastConnectsToAnyPad ||
lastConnectsToLogicallyConnectedTraceCopper ||
+ getPourConnectivity().endpointTouchesConnectedPour(
+ lastPoint,
+ trace.pcb_trace_id,
+ lastEndpointTraceCopperWidth ?? 0,
+ ) ||
(lastEndpointTraceCopperWidth !== undefined &&
endpointTouchesVia({
point: lastPoint,
diff --git a/lib/util/copper-pour-connectivity.ts b/lib/util/copper-pour-connectivity.ts
new file mode 100644
index 0000000..b360193
--- /dev/null
+++ b/lib/util/copper-pour-connectivity.ts
@@ -0,0 +1,482 @@
+import * as Flatten from "@flatten-js/core"
+import type {
+ AnyCircuitElement,
+ PcbCopperPour,
+ PcbPort,
+ PcbTrace,
+ PcbPlatedHole,
+} from "circuit-json"
+import { getPrimaryId } from "@tscircuit/circuit-json-util"
+import type { ConnectivityMap } from "circuit-json-to-connectivity-map"
+import {
+ brepRingToPolygon,
+ getSmtPadGeometry,
+ getPlatedHoleGeometry,
+} from "../check-copper-to-board-edge-clearance"
+import {
+ getRotatedRectPoints,
+ getPillCenterLineForPad,
+} from "../check-each-pcb-trace-non-overlapping/segment-to-polygon-clearance"
+import { getLayersOfPcbElement } from "./getLayersOfPcbElement"
+import {
+ SpatialObjectIndex,
+ type Bounds,
+} from "../data-structures/SpatialIndex"
+
+type Shape = Flatten.Polygon | Flatten.Segment | Flatten.Point
+// A segment/point with a radius is exact constant-width trace/circular copper.
+type Geometry = { shape: Shape; radius: number }
+type Copper = Geometry & {
+ index: number
+ id: string
+ net: string
+ layers: string[]
+ bounds: Bounds
+ isPour: boolean
+ portId?: string
+}
+const EPSILON = 1e-9
+
+const polygonFromPoints = (points: { x: number; y: number }[]) =>
+ new Flatten.Polygon(points.map(({ x, y }) => new Flatten.Point(x, y)))
+
+function pourPolygon(pour: PcbCopperPour): Flatten.Polygon | undefined {
+ if (pour.shape === "polygon") return polygonFromPoints(pour.points)
+ if (pour.shape === "rect")
+ return polygonFromPoints(
+ getRotatedRectPoints({
+ x: pour.center.x,
+ y: pour.center.y,
+ width: pour.width,
+ height: pour.height,
+ ccwRotation: pour.rotation ?? 0,
+ }),
+ )
+ const polygon = brepRingToPolygon(pour.brep_shape.outer_ring.vertices)
+ if (!polygon) return
+ for (const ring of pour.brep_shape.inner_rings) {
+ const hole = brepRingToPolygon(ring.vertices)
+ // An unrecognized hole must not be silently turned into conducting copper.
+ if (!hole) return
+ for (const face of hole.faces) polygon.addFace(face.shapes)
+ }
+ return polygon
+}
+
+function capsulePolygon(
+ start: Flatten.Point,
+ end: Flatten.Point,
+ radius: number,
+): Flatten.Polygon {
+ if (start.distanceTo(end)[0] <= EPSILON)
+ return new Flatten.Polygon(new Flatten.Circle(start, radius))
+ const angle = Math.atan2(end.y - start.y, end.x - start.x) + Math.PI / 2
+ const dx = radius * Math.cos(angle),
+ dy = radius * Math.sin(angle)
+ return new Flatten.Polygon([
+ new Flatten.Segment(
+ new Flatten.Point(start.x + dx, start.y + dy),
+ new Flatten.Point(end.x + dx, end.y + dy),
+ ),
+ new Flatten.Arc(end, radius, angle, angle - Math.PI, false),
+ new Flatten.Segment(
+ new Flatten.Point(end.x - dx, end.y - dy),
+ new Flatten.Point(start.x - dx, start.y - dy),
+ ),
+ new Flatten.Arc(start, radius, angle - Math.PI, angle - 2 * Math.PI, false),
+ ])
+}
+
+function platedHoleDrill(pad: PcbPlatedHole): Flatten.Polygon | undefined {
+ const x = pad.x + ("hole_offset_x" in pad ? pad.hole_offset_x : 0)
+ const y = pad.y + ("hole_offset_y" in pad ? pad.hole_offset_y : 0)
+ if ("hole_diameter" in pad && pad.hole_diameter !== undefined)
+ return pad.hole_diameter > 0
+ ? new Flatten.Polygon(
+ new Flatten.Circle(new Flatten.Point(x, y), pad.hole_diameter / 2),
+ )
+ : undefined
+ if (
+ !("hole_width" in pad && "hole_height" in pad) ||
+ pad.hole_width === undefined ||
+ pad.hole_height === undefined
+ )
+ return undefined
+ const rotation =
+ "hole_ccw_rotation" in pad
+ ? pad.hole_ccw_rotation
+ : "ccw_rotation" in pad
+ ? (pad.ccw_rotation ?? 0)
+ : 0
+ const line = getPillCenterLineForPad({
+ type: "pcb_smtpad",
+ pcb_smtpad_id: "drill",
+ shape: "rotated_pill",
+ layer: "top",
+ x,
+ y,
+ width: pad.hole_width,
+ height: pad.hole_height,
+ radius: Math.min(pad.hole_width, pad.hole_height) / 2,
+ ccw_rotation: rotation,
+ })
+ return capsulePolygon(
+ new Flatten.Point(line.start.x, line.start.y),
+ new Flatten.Point(line.end.x, line.end.y),
+ line.radius,
+ )
+}
+
+function platedCopperPolygon(
+ geometry: NonNullable<ReturnType<typeof getSmtPadGeometry>>,
+ drill: Flatten.Polygon | undefined,
+): Flatten.Polygon {
+ const shapes =
+ geometry.kind === "pill"
+ ? [
+ capsulePolygon(
+ geometry.centerLine.start,
+ geometry.centerLine.end,
+ geometry.radius,
+ ),
+ ]
+ : geometry.shapes.map((s) =>
+ s instanceof Flatten.Circle ? new Flatten.Polygon(s) : s,
+ )
+ const outer = shapes.reduce((a, b) => Flatten.BooleanOperations.unify(a, b))
+ return drill ? Flatten.BooleanOperations.subtract(outer, drill) : outer
+}
+
+function representativePoints(shape: Shape): Flatten.Point[] {
+ if (shape instanceof Flatten.Point) return [shape]
+ if (shape instanceof Flatten.Segment) return [shape.start, shape.end]
+ return shape.vertices
+}
+
+function touches(a: Geometry, b: Geometry): boolean {
+ const aShape = a.shape,
+ bShape = b.shape
+ if (
+ aShape instanceof Flatten.Polygon &&
+ representativePoints(bShape).some((p) => aShape.contains(p))
+ )
+ return true
+ if (
+ bShape instanceof Flatten.Polygon &&
+ representativePoints(aShape).some((p) => bShape.contains(p))
+ )
+ return true
+ return a.shape.distanceTo(b.shape)[0] <= a.radius + b.radius + EPSILON
+}
+
+function bounds({ shape, radius }: Geometry): Bounds {
+ const box = shape.box
+ return {
+ minX: box.xmin - radius,
+ minY: box.ymin - radius,
+ maxX: box.xmax + radius,
+ maxY: box.ymax + radius,
+ }
+}
+
+function overlap(a: Bounds, b: Bounds) {
+ return (
+ a.minX <= b.maxX + EPSILON &&
+ a.maxX + EPSILON >= b.minX &&
+ a.minY <= b.maxY + EPSILON &&
+ a.maxY + EPSILON >= b.minY
+ )
+}
+
+/**
+ * Physical copper connectivity for nets containing a pour. Logical net IDs only
+ * select eligible copper; every union requires actual contact on a common layer.
+ * Pours remain separate islands, including their BRep holes and circular arcs.
+ */
+export class CopperPourConnectivity {
+ private nodes: Copper[] = []
+ private parents: number[] = []
+ private byId = new Map<string, Copper[]>()
+ private poursByNet = new Map<string, Copper[]>()
+ private portsByNet = new Map<string, PcbPort[]>()
+ private groupsWithPour = new Set<number>()
+ private groupsWithPort = new Set<number>()
+ private sourceNetByPort = new Map<string, string>()
+
+ constructor(
+ circuitJson: AnyCircuitElement[],
+ private connectivity: ConnectivityMap,
+ ) {
+ const pours = circuitJson.filter((e) => e.type === "pcb_copper_pour")
+ const netIds = new Set(
+ pours.map((p) =>
+ p.source_net_id
+ ? connectivity.getNetConnectedToId(p.source_net_id)
+ : undefined,
+ ),
+ )
+ netIds.delete(undefined)
+ const add = (
+ id: string,
+ layers: string[],
+ geometry: Geometry,
+ net: string | undefined,
+ isPour = false,
+ portId?: string,
+ ) => {
+ if (!net || !netIds.has(net)) return
+ const node: Copper = {
+ ...geometry,
+ index: this.nodes.length,
+ id,
+ layers,
+ net,
+ isPour,
+ portId,
+ bounds: bounds(geometry),
+ }
+ if (!Object.values(node.bounds).every(Number.isFinite)) return
+ this.nodes.push(node)
+ this.parents.push(node.index)
+ this.byId.set(id, [...(this.byId.get(id) ?? []), node])
+ if (isPour)
+ this.poursByNet.set(net, [...(this.poursByNet.get(net) ?? []), node])
+ }
+ const addShapes = (
+ id: string,
+ layers: string[],
+ geometry: ReturnType<typeof getSmtPadGeometry>,
+ net: string | undefined,
+ portId?: string,
+ ) => {
+ if (!geometry) return
+ if (geometry.kind === "pill")
+ add(
+ id,
+ layers,
+ { shape: geometry.centerLine, radius: geometry.radius },
+ net,
+ false,
+ portId,
+ )
+ else
+ for (const shape of geometry.shapes)
+ add(
+ id,
+ layers,
+ shape instanceof Flatten.Circle
+ ? { shape: shape.center, radius: shape.r }
+ : { shape, radius: 0 },
+ net,
+ false,
+ portId,
+ )
+ }
+ for (const element of circuitJson) {
+ if (element.type === "pcb_port") {
+ const net = connectivity.getNetConnectedToId(element.pcb_port_id)
+ if (net && netIds.has(net)) {
+ this.sourceNetByPort.set(element.pcb_port_id, net)
+ this.portsByNet.set(net, [
+ ...(this.portsByNet.get(net) ?? []),
+ element,
+ ])
+ }
+ }
+ if (element.type === "pcb_copper_pour") {
+ const polygon = pourPolygon(element)
+ if (polygon)
+ add(
+ element.pcb_copper_pour_id,
+ [element.layer],
+ { shape: polygon, radius: 0 },
+ element.source_net_id
+ ? connectivity.getNetConnectedToId(element.source_net_id)
+ : undefined,
+ true,
+ )
+ }
+ if (element.type === "pcb_smtpad" || element.type === "pcb_plated_hole") {
+ const id = getPrimaryId(element)
+ const net =
+ connectivity.getNetConnectedToId(id) ??
+ (element.pcb_port_id
+ ? connectivity.getNetConnectedToId(element.pcb_port_id)
+ : undefined)
+ if (!net || !netIds.has(net)) continue
+ const geometry =
+ element.type === "pcb_smtpad"
+ ? getSmtPadGeometry(element)
+ : getPlatedHoleGeometry(element, 0)
+ if (element.type === "pcb_plated_hole" && geometry) {
+ const copper = platedCopperPolygon(geometry, platedHoleDrill(element))
+ if (!copper.isEmpty())
+ add(
+ id,
+ getLayersOfPcbElement(element),
+ { shape: copper, radius: 0 },
+ net,
+ false,
+ element.pcb_port_id,
+ )
+ } else
+ addShapes(
+ id,
+ getLayersOfPcbElement(element),
+ geometry,
+ net,
+ element.pcb_port_id,
+ )
+ }
+ if (element.type === "pcb_via") {
+ const net =
+ connectivity.getNetConnectedToId(element.pcb_via_id) ??
+ (element.pcb_trace_id
+ ? connectivity.getNetConnectedToId(element.pcb_trace_id)
+ : undefined)
+ if (!net || !netIds.has(net) || element.outer_diameter <= 0) continue
+ const center = new Flatten.Point(element.x, element.y)
+ const outer = new Flatten.Polygon(
+ new Flatten.Circle(center, element.outer_diameter / 2),
+ )
+ const copper =
+ element.hole_diameter > 0
+ ? Flatten.BooleanOperations.subtract(
+ outer,
+ new Flatten.Polygon(
+ new Flatten.Circle(center, element.hole_diameter / 2),
+ ),
+ )
+ : outer
+ if (!copper.isEmpty())
+ add(
+ element.pcb_via_id,
+ getLayersOfPcbElement(element),
+ { shape: copper, radius: 0 },
+ net,
+ )
+ }
+ if (element.type === "pcb_trace") {
+ // Interpolated traces need their actual variable-width outline. Do not
+ // infer a contact from the larger endpoint's circular cap.
+ if (element.route_thickness_mode === "interpolated") continue
+ const net = connectivity.getNetConnectedToId(element.pcb_trace_id)
+ if (!net || !netIds.has(net)) continue
+ for (let i = 1; i < element.route.length; i++) {
+ const a = element.route[i - 1]!,
+ b = element.route[i]!
+ if (
+ a.route_type !== "wire" ||
+ b.route_type !== "wire" ||
+ a.layer !== b.layer ||
+ a.width <= 0
+ )
+ continue
+ add(
+ element.pcb_trace_id,
+ [a.layer],
+ {
+ shape: new Flatten.Segment(
+ new Flatten.Point(a.x, a.y),
+ new Flatten.Point(b.x, b.y),
+ ),
+ radius: a.width / 2,
+ },
+ net,
+ )
+ }
+ }
+ }
+ const spatial = new SpatialObjectIndex<Copper>({
+ objects: this.nodes,
+ getBounds: (n) => n.bounds,
+ getId: (n) => String(n.index),
+ CELL_SIZE: 5,
+ })
+ for (const a of this.nodes) {
+ for (const b of spatial.getObjectsInBounds(a.bounds, EPSILON)) {
+ if (
+ b.index >= a.index ||
+ a.net !== b.net ||
+ !a.layers.some((l) => b.layers.includes(l)) ||
+ !overlap(a.bounds, b.bounds)
+ )
+ continue
+ if (this.root(a.index) === this.root(b.index)) continue
+ if (touches(a, b)) this.parents[this.root(a.index)] = this.root(b.index)
+ }
+ }
+ for (const node of this.nodes) {
+ if (node.isPour) this.groupsWithPour.add(this.root(node.index))
+ if (node.portId) this.groupsWithPort.add(this.root(node.index))
+ }
+ }
+
+ private root(index: number): number {
+ if (this.parents[index] !== index)
+ this.parents[index] = this.root(this.parents[index])
+ return this.parents[index]
+ }
+
+ private portGroups(portId: string): Set<number> {
+ return new Set(
+ this.nodes
+ .filter((n) => n.portId === portId)
+ .map((n) => this.root(n.index)),
+ )
+ }
+
+ /** A pour connection must physically reach the other required ports. */
+ portsConnectedThroughPour(portIds: string[]): boolean {
+ const first = portIds[0]
+ if (!first) return false
+ const groups = [...this.portGroups(first)].filter((group) =>
+ this.groupsWithPour.has(group),
+ )
+ return groups.some((group) =>
+ portIds.every((id) => this.portGroups(id).has(group)),
+ )
+ }
+
+ portConnectedToPourNet(portId: string): boolean {
+ const net = this.sourceNetByPort.get(portId)
+ if (!net) return false
+ const ports = this.portsByNet.get(net) ?? []
+ return (
+ ports.length > 0 &&
+ this.portsConnectedThroughPour(ports.map((p) => p.pcb_port_id))
+ )
+ }
+
+ traceConnectedToPortThroughPour(traceId: string, portId: string): boolean {
+ const groups = this.portGroups(portId)
+ return (this.byId.get(traceId) ?? []).some(
+ (node) =>
+ this.groupsWithPour.has(this.root(node.index)) &&
+ groups.has(this.root(node.index)),
+ )
+ }
+
+ endpointTouchesConnectedPour(
+ point: PcbTrace["route"][number],
+ traceId: string,
+ width: number,
+ ): boolean {
+ if (point.route_type !== "wire" || !Number.isFinite(width) || width < 0)
+ return false
+ const net = this.connectivity.getNetConnectedToId(traceId)
+ if (!net) return false
+ const endpoint = {
+ shape: new Flatten.Point(point.x, point.y),
+ radius: width / 2,
+ }
+ const endpointBounds = bounds(endpoint)
+ return (this.poursByNet.get(net) ?? []).some(
+ (pour) =>
+ pour.layers.includes(point.layer) &&
+ this.groupsWithPort.has(this.root(pour.index)) &&
+ overlap(endpointBounds, pour.bounds) &&
+ touches(endpoint, pour),
+ )
+ }
+}
diff --git a/tests/lib/copper-pour-connectivity.test.ts b/tests/lib/copper-pour-connectivity.test.ts
new file mode 100644
index 0000000..e2539c5
--- /dev/null
+++ b/tests/lib/copper-pour-connectivity.test.ts
@@ -0,0 +1,459 @@
+import { describe, expect, test } from "bun:test"
+import type { AnyCircuitElement, PcbCopperPour, PcbTrace } from "circuit-json"
+import { checkEachPcbPortConnectedToPcbTraces } from "lib/check-each-pcb-port-connected-to-pcb-trace"
+import { CopperPourConnectivity } from "lib/util/copper-pour-connectivity"
+import { getFullConnectivityMapFromCircuitJson } from "circuit-json-to-connectivity-map"
+import { checkTracesAreContiguous } from "lib/check-traces-are-contiguous/check-traces-are-contiguous"
+
+type Layer = "top" | "bottom"
+const net = "source_net_ground"
+const wire = (
+ x: number,
+ y: number,
+ layer: Layer = "bottom",
+): PcbTrace["route"][number] => ({
+ route_type: "wire",
+ x,
+ y,
+ layer,
+ width: 0.1,
+})
+function pad(
+ id: string,
+ x: number,
+ y: number,
+ layer: Layer = "bottom",
+ sourceNetId = net,
+): AnyCircuitElement[] {
+ return [
+ {
+ type: "source_trace",
+ source_trace_id: `source_trace_${id}`,
+ connected_source_port_ids: [`source_port_${id}`],
+ connected_source_net_ids: [sourceNetId],
+ },
+ {
+ type: "pcb_port",
+ pcb_port_id: `pcb_port_${id}`,
+ source_port_id: `source_port_${id}`,
+ pcb_component_id: `pcb_component_${id}`,
+ x,
+ y,
+ layers: [layer],
+ },
+ {
+ type: "pcb_smtpad",
+ pcb_smtpad_id: `pcb_smtpad_${id}`,
+ pcb_port_id: `pcb_port_${id}`,
+ pcb_component_id: `pcb_component_${id}`,
+ shape: "rect",
+ x,
+ y,
+ width: 0.3,
+ height: 0.3,
+ layer,
+ },
+ ]
+}
+function rect(
+ id: string,
+ x: number,
+ y: number,
+ width = 2,
+ height = 2,
+ layer: Layer = "bottom",
+ sourceNetId = net,
+): PcbCopperPour {
+ return {
+ type: "pcb_copper_pour",
+ covered_with_solder_mask: true,
+ pcb_copper_pour_id: `pcb_copper_pour_${id}`,
+ shape: "rect",
+ center: { x, y },
+ width,
+ height,
+ layer,
+ source_net_id: sourceNetId,
+ }
+}
+function trace(
+ route: PcbTrace["route"],
+ sourceNetId = net,
+): AnyCircuitElement[] {
+ return [
+ {
+ type: "source_trace",
+ source_trace_id: "source_trace_bridge",
+ connected_source_port_ids: [],
+ connected_source_net_ids: [sourceNetId],
+ },
+ {
+ type: "pcb_trace",
+ pcb_trace_id: "pcb_trace_bridge",
+ source_trace_id: "source_trace_bridge",
+ route,
+ },
+ ]
+}
+const endpointErrors = (circuit: AnyCircuitElement[]) =>
+ checkTracesAreContiguous(circuit).filter(
+ (e) => e.pcb_trace_id === "pcb_trace_bridge",
+ )
+function rectangleVertices(x: number, y: number, half: number) {
+ return [
+ { x: x - half, y: y - half },
+ { x: x + half, y: y - half },
+ { x: x + half, y: y + half },
+ { x: x - half, y: y + half },
+ ]
+}
+function holedPour(): PcbCopperPour {
+ return {
+ type: "pcb_copper_pour",
+ covered_with_solder_mask: true,
+ pcb_copper_pour_id: "pcb_copper_pour_hole",
+ source_net_id: net,
+ shape: "brep",
+ layer: "bottom",
+ brep_shape: {
+ outer_ring: { vertices: rectangleVertices(0, 0, 4) },
+ inner_rings: [{ vertices: rectangleVertices(0, 0, 1) }],
+ },
+ }
+}
+
+describe("physical copper-pour connectivity", () => {
+ test("connects port-to-net traces through same-layer copper with no PCB traces", () => {
+ expect(
+ checkEachPcbPortConnectedToPcbTraces([
+ ...pad("a", -1, 0),
+ ...pad("b", 1, 0),
+ rect("all", 0, 0, 4, 2),
+ ]),
+ ).toEqual([])
+ })
+
+ test("connects an explicit two-port source trace entirely through a pour", () => {
+ const circuit: AnyCircuitElement[] = [
+ ...pad("a", -1, 0),
+ ...pad("b", 1, 0),
+ rect("all", 0, 0, 4, 2),
+ ].filter((e) => e.type !== "source_trace")
+ circuit.push({
+ type: "source_trace",
+ source_trace_id: "source_trace_pair",
+ connected_source_port_ids: ["source_port_a", "source_port_b"],
+ connected_source_net_ids: [net],
+ })
+ expect(checkEachPcbPortConnectedToPcbTraces(circuit)).toEqual([])
+ })
+
+ test("does not connect two separate islands just because they have the same net ID", () => {
+ expect(
+ checkEachPcbPortConnectedToPcbTraces([
+ ...pad("a", -2, 0),
+ ...pad("b", 2, 0),
+ rect("left", -2, 0),
+ rect("right", 2, 0),
+ ]),
+ ).toHaveLength(2)
+ })
+
+ test.each(["wrong net", "wrong layer"])(
+ "does not connect a pad using a pour on the %s",
+ (reason) => {
+ expect(
+ checkEachPcbPortConnectedToPcbTraces([
+ ...pad("a", 0, 0),
+ rect(
+ "wrong",
+ 0,
+ 0,
+ 2,
+ 2,
+ reason === "wrong layer" ? "top" : "bottom",
+ reason === "wrong net" ? "source_net_other" : net,
+ ),
+ ]),
+ ).toHaveLength(1)
+ },
+ )
+
+ test("keeps a pad entirely inside a BRep hole disconnected", () => {
+ expect(
+ checkEachPcbPortConnectedToPcbTraces([
+ ...pad("a", 0, 0),
+ ...pad("b", 2, 0),
+ holedPour(),
+ ]),
+ ).toHaveLength(2)
+ })
+
+ test("joins islands through actual same-layer trace copper", () => {
+ const circuit = [
+ ...pad("a", -2, 0),
+ ...pad("b", 2, 0),
+ rect("left", -2, 0),
+ rect("right", 2, 0),
+ ...trace([wire(-1.5, 0), wire(1.5, 0)]),
+ ]
+ expect(checkEachPcbPortConnectedToPcbTraces(circuit)).toEqual([])
+ expect(endpointErrors(circuit)).toEqual([])
+ })
+
+ test("does not join islands through a bridge on the wrong layer", () => {
+ const circuit = [
+ ...pad("a", -2, 0),
+ ...pad("b", 2, 0),
+ rect("left", -2, 0),
+ rect("right", 2, 0),
+ ...trace([wire(-1.5, 0, "top"), wire(1.5, 0, "top")]),
+ ]
+ expect(checkEachPcbPortConnectedToPcbTraces(circuit)).toHaveLength(2)
+ expect(endpointErrors(circuit)).toHaveLength(2)
+ })
+
+ test("joins top and bottom pours through a plated via", () => {
+ const circuit: AnyCircuitElement[] = [
+ ...pad("a", -2, 0, "top"),
+ ...pad("b", 2, 0),
+ rect("left", -1, 0, 4, 2, "top"),
+ rect("right", 1, 0, 4, 2),
+ ...trace([wire(-0.5, 0, "top"), wire(0, 0, "top")]),
+ {
+ type: "pcb_via",
+ pcb_via_id: "pcb_via_bridge",
+ pcb_trace_id: "pcb_trace_bridge",
+ x: 0,
+ y: 0,
+ outer_diameter: 0.4,
+ hole_diameter: 0.2,
+ layers: ["top", "bottom"],
+ },
+ ]
+ expect(checkEachPcbPortConnectedToPcbTraces(circuit)).toEqual([])
+ expect(
+ checkEachPcbPortConnectedToPcbTraces(
+ circuit.filter((e) => e.type !== "pcb_via"),
+ ),
+ ).toHaveLength(2)
+ })
+
+ test("recognizes a pour contact at an endpoint directly adjacent to a via", () => {
+ const circuit = [
+ ...pad("a", -2, 0),
+ rect("all", 0, 0, 6, 4),
+ ...trace([
+ wire(-2, 0),
+ {
+ route_type: "via",
+ x: -2,
+ y: 0,
+ from_layer: "bottom",
+ to_layer: "top",
+ outer_diameter: 0.4,
+ },
+ wire(-2, 0, "top"),
+ wire(0, 0, "top"),
+ {
+ route_type: "via",
+ x: 0,
+ y: 0,
+ from_layer: "top",
+ to_layer: "bottom",
+ outer_diameter: 0.4,
+ },
+ wire(0, 0),
+ ]),
+ ]
+ expect(endpointErrors(circuit)).toEqual([])
+ })
+
+ test("preserves a real floating endpoint even when another part of the net reaches a pour", () => {
+ const circuit = [
+ ...pad("a", -2, 0),
+ rect("left", -2, 0),
+ ...trace([wire(-2, 0), wire(0, 0)]),
+ ]
+ expect(endpointErrors(circuit).map((e) => e.pcb_trace_error_id)).toEqual([
+ "disconnected_endpoint_pcb_trace_bridge_end",
+ ])
+ })
+
+ test("keeps an endpoint inside a BRep hole disconnected", () => {
+ const circuit = [
+ ...pad("a", -2, 0),
+ holedPour(),
+ ...trace([wire(-2, 0), wire(0, 0)]),
+ ]
+ expect(endpointErrors(circuit).map((e) => e.pcb_trace_error_id)).toEqual([
+ "disconnected_endpoint_pcb_trace_bridge_end",
+ ])
+ })
+
+ test("does not treat a floating pour and trace loop as a pad connection", () => {
+ const circuit = [
+ ...pad("a", -4, 0),
+ rect("floating", 0, 0),
+ ...trace([wire(-0.5, 0), wire(0.5, 0)]),
+ ]
+ expect(endpointErrors(circuit)).toHaveLength(2)
+ expect(checkEachPcbPortConnectedToPcbTraces(circuit)).toHaveLength(1)
+ })
+
+ test.each(["wrong net", "wrong layer"])(
+ "keeps endpoint contact with the %s as a real error",
+ (reason) => {
+ const circuit = [
+ ...pad("a", -2, 0),
+ ...pad(
+ "b",
+ 0,
+ 0.5,
+ reason === "wrong layer" ? "top" : "bottom",
+ reason === "wrong net" ? "source_net_other" : net,
+ ),
+ rect(
+ "wrong",
+ 0,
+ 0,
+ 2,
+ 2,
+ reason === "wrong layer" ? "top" : "bottom",
+ reason === "wrong net" ? "source_net_other" : net,
+ ),
+ ...trace([wire(-2, 0), wire(0, 0)]),
+ ]
+ expect(endpointErrors(circuit).map((e) => e.pcb_trace_error_id)).toEqual([
+ "disconnected_endpoint_pcb_trace_bridge_end",
+ ])
+ },
+ )
+
+ test("follows a routed trace through the pour to its expected destination pad", () => {
+ const circuit = [
+ ...pad("a", -3, 0),
+ ...pad("b", 1, 0),
+ rect("right", 1, 0, 3, 2),
+ ...trace([wire(-3, 0), wire(0, 0)]),
+ ]
+ const source = circuit.find(
+ (e) =>
+ e.type === "source_trace" &&
+ e.source_trace_id === "source_trace_bridge",
+ )!
+ if (source.type === "source_trace")
+ source.connected_source_port_ids = ["source_port_a", "source_port_b"]
+ expect(endpointErrors(circuit)).toEqual([])
+ expect(
+ endpointErrors(circuit.filter((e) => e.type !== "pcb_copper_pour")),
+ ).toHaveLength(1)
+ })
+})
+
+describe("copper region holes and arcs", () => {
+ test.each(["via", "plated pad"])(
+ "does not bridge copper through the empty drill of a %s",
+ (kind) => {
+ const circuit: AnyCircuitElement[] = [
+ ...pad("a", 0, 0),
+ ...pad("b", 2, 0, "top"),
+ rect("top", 1, 0, 4, 2, "top"),
+ ...trace([wire(1, 0, "top"), wire(2, 0, "top")]),
+ ]
+ if (kind === "via")
+ circuit.push({
+ type: "pcb_via",
+ pcb_via_id: "pcb_via_annular",
+ pcb_trace_id: "pcb_trace_bridge",
+ x: 0,
+ y: 0,
+ outer_diameter: 2,
+ hole_diameter: 1,
+ layers: ["top", "bottom"],
+ })
+ else
+ circuit.push(
+ ...pad("annular", 0, 0).filter((e) => e.type !== "pcb_smtpad"),
+ {
+ type: "pcb_plated_hole",
+ pcb_plated_hole_id: "pcb_plated_hole_annular",
+ pcb_port_id: "pcb_port_annular",
+ shape: "circle",
+ x: 0,
+ y: 0,
+ outer_diameter: 2,
+ hole_diameter: 1,
+ layers: ["top", "bottom"],
+ },
+ )
+ const graph = new CopperPourConnectivity(
+ circuit,
+ getFullConnectivityMapFromCircuitJson(circuit),
+ )
+ expect(
+ graph.portsConnectedThroughPour(["pcb_port_a", "pcb_port_b"]),
+ ).toBe(false)
+ },
+ )
+
+ test("retains curved BRep copper between an arc and its chord", () => {
+ const bulge = Math.tan(Math.PI / 8)
+ const pour: PcbCopperPour = {
+ type: "pcb_copper_pour",
+ covered_with_solder_mask: true,
+ pcb_copper_pour_id: "pcb_copper_pour_curved",
+ source_net_id: net,
+ layer: "bottom",
+ shape: "brep",
+ brep_shape: {
+ outer_ring: {
+ vertices: [
+ { x: 2, y: 0, bulge },
+ { x: 0, y: 2, bulge },
+ { x: -2, y: 0, bulge },
+ { x: 0, y: -2, bulge },
+ ],
+ },
+ inner_rings: [],
+ },
+ }
+ expect(
+ checkEachPcbPortConnectedToPcbTraces([
+ ...pad("a", 0, 0),
+ ...pad("b", 1.3, 1.3),
+ pour,
+ ]),
+ ).toEqual([])
+ expect(
+ checkEachPcbPortConnectedToPcbTraces([
+ ...pad("a", 0, 0),
+ ...pad("b", 1.6, 1.6),
+ pour,
+ ]),
+ ).toHaveLength(2)
+ })
+
+ test("never equates a polygon pour bounding box with its copper", () => {
+ const pour: PcbCopperPour = {
+ type: "pcb_copper_pour",
+ covered_with_solder_mask: true,
+ pcb_copper_pour_id: "pcb_copper_pour_triangle",
+ source_net_id: net,
+ layer: "bottom",
+ shape: "polygon",
+ points: [
+ { x: -2, y: -2 },
+ { x: 2, y: -2 },
+ { x: -2, y: 2 },
+ ],
+ }
+ expect(
+ checkEachPcbPortConnectedToPcbTraces([
+ ...pad("a", -1, -1),
+ ...pad("b", 1, 1),
+ pour,
+ ]),
+ ).toHaveLength(2)
+ })
+})
diff --git a/tests/lib/brep-short-edge.test.ts b/tests/lib/brep-short-edge.test.ts
new file mode 100644
index 0000000..73372c6
--- /dev/null
+++ b/tests/lib/brep-short-edge.test.ts
@@ -0,0 +1,18 @@
+import { expect, test } from "bun:test"
+import Flatten from "@flatten-js/core"
+import { brepRingToPolygon } from "../../lib/check-copper-to-board-edge-clearance"
+
+test("pour edges below the geometry library tolerance do not crash distance checks", () => {
+ const polygon = brepRingToPolygon([
+ { x: 0, y: 0 },
+ { x: 2, y: 0 },
+ { x: 2, y: 0.0000008 },
+ { x: 2, y: 2 },
+ { x: 0, y: 2 },
+ { x: 0.0000008, y: 0.0000008 },
+ ])!
+ const obstacle = new Flatten.Circle(new Flatten.Point(3, 0.0000004), 0.2)
+ expect(polygon.distanceTo(obstacle)[0]).toBeCloseTo(0.8, 6)
+ expect(polygon.contains(new Flatten.Point(1, 1))).toBe(true)
+ expect(polygon.contains(new Flatten.Point(3, 1))).toBe(false)
+})