0hmX/dual-usb-peltier-assembly
A tscircuit package created by 0hmX.
- Version
- 0.4.12
- License
- unset
- Stars
- 0
tests/helpers/physical-copper.ts
import { Arc, Box, Circle, Point, Polygon, Segment, Vector } from "@flatten-js/core"
import Flatbush from "flatbush"
import type { AnyCircuitElement } from "circuit-json"
// Geometry tolerance only (1 nm), never a manufacturing-clearance exemption.
const EPS = 1e-6
type Element = AnyCircuitElement & Record<string, any>
type Node = { shape: Polygon; layer: string; id: string; type: string; port?: string }
class Union {
parents = new Map<string, string>()
root(id: string): string {
const parent = this.parents.get(id)
if (!parent) { this.parents.set(id, id); return id }
if (parent === id) return id
const root = this.root(parent)
this.parents.set(id, root)
return root
}
join(ids: string[]) {
if (!ids.length) return
const first = this.root(ids[0]!)
for (const id of ids.slice(1)) this.parents.set(this.root(id), first)
}
}
const append = <T>(map: Map<string, T[]>, key: string, value: T) =>
map.set(key, [...map.get(key) ?? [], value])
export function sourceConnectivity(json: AnyCircuitElement[]) {
const elements = json as Element[]
const components = new Map(elements.filter(e => e.type === "source_component").map(e => [e.source_component_id, e]))
const union = new Union(), internal: string[][] = []
for (const e of elements) {
if (e.type === "source_trace") union.join([...e.connected_source_port_ids, ...e.connected_source_net_ids])
if (e.type === "source_component") internal.push(...e.internally_connected_source_port_ids ?? [])
if (e.type === "source_component_internal_connection") internal.push(e.source_port_ids)
}
for (const ids of internal) union.join(ids)
const ports = new Map<string, string>(), sourceToKey = new Map<string, string>()
for (const port of elements.filter(e => e.type === "source_port" && components.has(e.source_component_id ?? ""))) {
const key = `${components.get(port.source_component_id)!.name}.${port.pin_number ?? port.name}`
if (ports.has(key)) throw new Error(`Duplicate physical pin identity: ${key}`)
ports.set(key, port.source_port_id)
sourceToKey.set(port.source_port_id, key)
}
const names = new Map<string, string[]>(), peers = new Map<string, string[]>()
for (const e of elements.filter(e => e.type === "source_net")) append(names, union.root(e.source_net_id), e.name)
for (const [key, id] of ports) append(peers, union.root(id), key)
const groups = [...peers].map(([id, keys]) => ({ nets: [...names.get(id) ?? []].sort(), ports: keys.sort() }))
.sort((a, b) => a.ports.join() < b.ports.join() ? -1 : a.ports.join() > b.ports.join() ? 1 : 0)
const internalKeys = internal.map(ids => ids.map(id => sourceToKey.get(id)).filter((id): id is string => !!id))
return { ports, groups, internalKeys, signature: JSON.stringify(groups) }
}
export type ExpectedConnectivity = ReturnType<typeof sourceConnectivity>
const point = (p: { x: number; y: number }) => new Point(p.x, p.y)
const disk = (x: number, y: number, r: number) => new Polygon(new Circle(new Point(x, y), r))
function rectangle(x: number, y: number, w: number, h: number, degrees = 0) {
return new Polygon(new Box(-w / 2, -h / 2, w / 2, h / 2)).rotate(degrees * Math.PI / 180).translate(new Vector(x, y))
}
function stroke(a: { x: number; y: number }, b: { x: number; y: number }, r1: number, r2 = r1) {
const length = Math.hypot(b.x - a.x, b.y - a.y)
if (![a.x, a.y, b.x, b.y, r1, r2].every(Number.isFinite) || Math.min(r1, r2) <= 0)
throw new Error("Invalid trace coordinate or width")
if (length <= Math.abs(r1 - r2) + EPS) return r1 >= r2 ? disk(a.x, a.y, r1) : disk(b.x, b.y, r2)
const angle = Math.acos((r1 - r2) / length)
const lowerA = new Point(r1 * Math.cos(-angle), r1 * Math.sin(-angle))
const lowerB = new Point(length + r2 * Math.cos(-angle), r2 * Math.sin(-angle))
const upperA = new Point(r1 * Math.cos(angle), r1 * Math.sin(angle))
const upperB = new Point(length + r2 * Math.cos(angle), r2 * Math.sin(angle))
return new Polygon([
new Segment(lowerA, lowerB), new Arc(new Point(length, 0), r2, -angle, angle, true),
new Segment(upperB, upperA), new Arc(new Point(0, 0), r1, angle, 2 * Math.PI - angle, true),
]).rotate(Math.atan2(b.y - a.y, b.x - a.x)).translate(new Vector(a.x, a.y))
}
function capsule(x: number, y: number, w: number, h: number, degrees = 0) {
const half = Math.abs(w - h) / 2, radius = Math.min(w, h) / 2
return stroke({ x: w > h ? -half : 0, y: w > h ? 0 : -half },
{ x: w > h ? half : 0, y: w > h ? 0 : half }, radius)
.rotate(degrees * Math.PI / 180).translate(new Vector(x, y))
}
function cutHole(outer: Polygon, hole: Polygon) {
const orientation = outer.faces.values().next().value!.orientation()
for (const boundary of hole.faces) {
const face = outer.addFace(boundary.shapes)
if (face.orientation() === orientation) face.reverse()
}
return outer
}
function padShape(e: Element, components: Map<string, Element>) {
const { x, y } = e, rotation = e.ccw_rotation ?? 0
if (e.type === "pcb_via") return cutHole(disk(x, y, e.outer_diameter / 2), disk(x, y, e.hole_diameter / 2))
const plated = e.type === "pcb_plated_hole"
const w = plated ? e.outer_width : e.width, h = plated ? e.outer_height : e.height
let shape: Polygon
if (e.shape === "circle") shape = disk(x, y, plated ? e.outer_diameter / 2 : e.radius)
else if (["rect", "rotated_rect", "circle_with_rect_pad"].includes(e.shape)) shape = rectangle(x, y, w, h, rotation)
else if (["pill", "rotated_pill", "oval"].includes(e.shape)) shape = capsule(x, y, w, h, rotation)
else if (e.shape === "polygon") {
shape = new Polygon(e.points.map(point))
if (plated) shape = shape.rotate((components.get(e.pcb_component_id ?? "")?.rotation ?? 0) * Math.PI / 180).translate(new Vector(x, y))
} else throw new Error(`Unsupported copper shape: ${e.type} ${e.shape}`)
if (plated) cutHole(shape, e.hole_diameter ? disk(x, y, e.hole_diameter / 2) : capsule(x, y, e.hole_width, e.hole_height, rotation))
return shape
}
function pourShape(e: Element) {
if (e.shape === "rect") return rectangle(e.center.x, e.center.y, e.width, e.height, e.rotation)
if (e.shape === "polygon") return new Polygon(e.points.map(point))
if (e.shape !== "brep") throw new Error(`Unsupported pour shape: ${e.shape}`)
const shape = new Polygon(e.brep_shape.outer_ring.vertices.map(point))
for (const ring of e.brep_shape.inner_rings ?? []) cutHole(shape, new Polygon(ring.vertices.map(point)))
return shape
}
function touches(a: Polygon, b: Polygon) {
return a.contains(b.vertices[0]!) || b.contains(a.vertices[0]!) || a.distanceTo(b)[0] <= EPS
}
/** Drill webs are a fabrication constraint even when the copper shares a net. */
export function drillSmtClearanceViolations(json: AnyCircuitElement[], thermalPairs = new Set<string>()) {
const elements = json as Element[], minimum = 0.15
const components = new Map(elements.filter(e => e.type === "pcb_component").map(e => [e.pcb_component_id, e]))
const pads = elements.filter(e => e.type === "pcb_smtpad").map(e => ({ id: e.pcb_smtpad_id, shape: padShape(e, components) }))
const violations: { drill: string; pad: string; actual: number; minimum: number }[] = []
if (!pads.length) return violations
const index = new Flatbush(pads.length)
for (const { shape } of pads) { const b = shape.box; index.add(b.xmin, b.ymin, b.xmax, b.ymax) }
index.finish()
for (const e of elements.filter(e => e.type === "pcb_via" || e.type === "pcb_plated_hole")) {
const id = e[`${e.type}_id`]
const drill = e.hole_diameter ? disk(e.x, e.y, e.hole_diameter / 2)
: capsule(e.x, e.y, e.hole_width, e.hole_height, e.ccw_rotation ?? 0)
const b = drill.box
for (const i of index.search(b.xmin - minimum, b.ymin - minimum, b.xmax + minimum, b.ymax + minimum)) {
const pad = pads[i]!
if (e.type === "pcb_via" && thermalPairs.has(`via_in_pad_${id}_${pad.id}`)) continue
const actual = touches(drill, pad.shape) ? 0 : drill.distanceTo(pad.shape)[0]
if (actual < minimum - EPS) violations.push({ drill: id, pad: pad.id, actual, minimum })
}
}
return violations
}
/** Endpoints inside real pad copper count even when displaced from pad center. */
export function copperPortContacts(json: AnyCircuitElement[]) {
const elements = json as Element[]
const components = new Map(elements.filter(e => e.type === "pcb_component").map(e => [e.pcb_component_id, e]))
const ports = new Map(elements.filter(e => e.type === "pcb_port").map(e => [e.pcb_port_id, e]))
const pads = elements.filter(e => e.type === "pcb_smtpad" || e.type === "pcb_plated_hole").map(e => ({
element: e, shape: padShape(e, components), port: ports.get(e.pcb_port_id ?? ""),
layers: (e.type === "pcb_smtpad" ? [e.layer] : e.layers) as string[],
})).filter(pad => pad.port?.source_port_id)
return (endpoints: { x: number; y: number; layers: string[] }[]) => new Set<string>(pads.filter(pad =>
endpoints.some(endpoint => endpoint.layers.some(layer => pad.layers.includes(layer)) &&
(pad.shape.contains(point(endpoint)) || pad.shape.distanceTo(point(endpoint))[0] <= EPS ||
// Through-hole routes conventionally terminate at the drill center.
(pad.element.type === "pcb_plated_hole" && Math.hypot(endpoint.x - pad.port!.x, endpoint.y - pad.port!.y) <= EPS))))
.map(pad => pad.port!.source_port_id))
}
export function analyzePhysicalCopper(json: AnyCircuitElement[], expected = sourceConnectivity(json)) {
const elements = json as Element[], current = sourceConnectivity(json)
const board = elements.find(e => e.type === "pcb_board")!
if (!board) throw new Error("Missing board")
const stack = ["top", ...Array.from({ length: board.num_layers - 2 }, (_, i) => `inner${i + 1}`), "bottom"]
const components = new Map(elements.filter(e => e.type === "pcb_component").map(e => [e.pcb_component_id, e]))
const pcbPorts = new Map(elements.filter(e => e.type === "pcb_port").map(e => [e.pcb_port_id, e]))
const sourceToKey = new Map([...current.ports].map(([key, id]) => [id, key]))
const nodes: Node[] = [], union = new Union(), portNodes = new Map<string, string[]>()
const segmentLengths = new Map<string, number>(), viaIds = new Set<string>(), invalid: string[] = []
const add = (shape: Polygon, layer: string, id: string, type: string, port?: string) => {
if (!stack.includes(layer)) throw new Error(`Unknown copper layer: ${layer}`)
const index = String(nodes.length)
nodes.push({ shape, layer, id, type, port }); union.root(index)
if (port) append(portNodes, port, index)
return index
}
for (const e of elements) {
if (["pcb_smtpad", "pcb_plated_hole", "pcb_via"].includes(e.type)) {
const shape = padShape(e, components), id = e[`${e.type}_id`]
let layers = e.type === "pcb_smtpad" ? [e.layer] : e.layers ?? [e.from_layer ?? "top", e.to_layer ?? "bottom"]
if (e.type !== "pcb_smtpad") {
const indices = layers.map((layer: string) => stack.indexOf(layer))
if (indices.includes(-1)) throw new Error(`Unknown barrel layer: ${id}`)
layers = stack.slice(Math.min(...indices), Math.max(...indices) + 1)
}
const port = sourceToKey.get(pcbPorts.get(e.pcb_port_id ?? "")?.source_port_id ?? "")
const ids = layers.map((layer: string) => add(shape, layer, id, e.type, port))
if (e.type !== "pcb_smtpad") union.join(ids)
if (e.type === "pcb_via") viaIds.add(id)
} else if (e.type === "pcb_copper_pour") {
const shape = pourShape(e)
if (!shape.isValid()) invalid.push(e.pcb_copper_pour_id)
add(shape, e.layer, e.pcb_copper_pour_id, e.type)
} else if (e.type === "pcb_trace") {
for (let i = 1; i < e.route.length; i++) {
const a = e.route[i - 1], b = e.route[i]
if (a.route_type === "via" && b.route_type === "via") continue
const aPoint = a.route_type === "through_pad" ? a.end : a
const bPoint = b.route_type === "through_pad" ? b.start : b
const aLayer = a.route_type === "wire" ? a.layer : a.route_type === "via" ? a.to_layer : a.end_layer
const bLayer = b.route_type === "wire" ? b.layer : b.route_type === "via" ? b.from_layer : b.start_layer
if (aLayer !== bLayer) throw new Error(`Unplated layer transition: ${e.pcb_trace_id}`)
// Native routes may omit repeated wire points at a via or pad boundary.
// The pad itself must exist as real copper; metadata cannot bridge it.
const aWidth = a.route_type !== "via" ? a.width : b.route_type !== "via" ? b.width : 0
const bWidth = b.route_type !== "via" ? b.width : aWidth
const length = Math.hypot(bPoint.x - aPoint.x, bPoint.y - aPoint.y)
// A repeated endpoint entirely inside a drill leaves no fabricated
// copper disk. The annular via/pad already models its real copper.
if (length <= EPS && elements.some(hole =>
(hole.type === "pcb_via" || hole.type === "pcb_plated_hole") && hole.hole_diameter &&
Math.hypot(aPoint.x - hole.x, aPoint.y - hole.y) + Math.max(aWidth, bWidth) / 2 <= hole.hole_diameter / 2 + EPS)) continue
const id = `${e.pcb_trace_id}:segment${i - 1}`
add(stroke(aPoint, bPoint, aWidth / 2, e.route_thickness_mode === "interpolated" ? bWidth / 2 : aWidth / 2), aLayer, id, e.type)
segmentLengths.set(id, length)
}
}
}
for (const ids of portNodes.values()) union.join(ids)
// Preserve original package metal (USB shells) and include verified conductors
// added to current component definitions (USBLC6 flow-through pins). Desired
// electrical nets still come exclusively from the immutable expected graph.
for (const keys of [...expected.internalKeys, ...current.internalKeys]) union.join(keys.flatMap(key => portNodes.get(key) ?? []))
const layerIndices: { ids: number[]; index: Flatbush }[] = []
for (const layer of stack) {
const ids = nodes.flatMap((node, i) => node.layer === layer ? [i] : [])
if (!ids.length) continue
const index = new Flatbush(ids.length)
for (const id of ids) { const b = nodes[id]!.shape.box; index.add(b.xmin, b.ymin, b.xmax, b.ymax) }
index.finish()
layerIndices.push({ ids, index })
ids.forEach((id, i) => {
const a = nodes[id]!.shape, b = a.box
for (const j of index.search(b.xmin - EPS, b.ymin - EPS, b.xmax + EPS, b.ymax + EPS))
if (j > i && touches(a, nodes[ids[j]!]!.shape)) union.join([String(id), String(ids[j])])
})
}
const expectedNets = new Map<string, string[]>(), groups = [], disconnected = []
let requiredPortCount = 0, ignoredNcPortCount = 0
for (const group of expected.groups) {
for (const key of group.ports) expectedNets.set(key, group.nets)
if (!group.nets.length && group.ports.length === 1) { ignoredNcPortCount++; continue }
requiredPortCount += group.ports.length
const islands = new Map<string, string[]>()
for (const port of group.ports) append(islands, portNodes.has(port) ? union.root(portNodes.get(port)![0]!) : `missing:${port}`, port)
const result = { ...group, islands: [...islands.values()] }
groups.push(result)
if (islands.size > 1) disconnected.push(result)
}
const physicalGroups = new Map<string, string[]>()
for (const [port, ids] of portNodes) if (expected.ports.has(port)) append(physicalGroups, union.root(ids[0]!), port)
const shorts = [...physicalGroups.values()].flatMap(ports => {
const nets = [...new Set(ports.map(port => expectedNets.get(port)?.join(",") || `NC:${port}`))]
return nets.length > 1 ? [{ nets, ports }] : []
})
// Resolve copper nets only through physical contact with original pins. A
// short cannot redefine the intended source nets: shorts above fail first.
const rootNets = new Map([...physicalGroups].map(([root, ports]) => [root,
[...new Set(ports.map(port => expectedNets.get(port)?.join(",") || `NC:${port}`))]]))
const elementsById = new Map(elements.map(e => [e[`${e.type}_id`], e]))
const namedNet = (id: string) => {
const name = elementsById.get(id)?.name
return expected.groups.find(group => group.nets.includes(name))?.nets.join(",")
}
const declaredNet = (element?: Element): string | undefined => {
if (!element) return
if (element.source_net_id) return namedNet(element.source_net_id)
if (element.source_trace_id) {
const trace = elementsById.get(element.source_trace_id)
const key = trace?.connected_source_port_ids.map((id: string) => sourceToKey.get(id)).find((key: string) => expectedNets.has(key))
return key ? expectedNets.get(key)?.join(",") : trace?.connected_source_net_ids.map(namedNet).find(Boolean)
}
if (element.type === "pcb_via" && element.pcb_trace_id) return declaredNet(elementsById.get(element.pcb_trace_id))
const port = sourceToKey.get(pcbPorts.get(element.pcb_port_id ?? "")?.source_port_id ?? "")
return port ? expectedNets.get(port)?.join(",") || `NC:${port}` : undefined
}
const copperNet = (node: Node, root: string) => {
const actual = rootNets.get(root)
// A unique physically anchored net always overrides replayed metadata.
// For an open/shorted intermediate candidate only, declarations distinguish
// intended same-net islands. Opens, shorts and floating copper still fail.
return actual?.length === 1 ? actual[0] : declaredNet(elementsById.get(node.id.split(":segment")[0]))
}
const padGap = board.min_pad_edge_to_pad_edge_clearance ?? 0.1
const traceGap = board.min_trace_to_pad_edge_clearance ?? 0.1
const traceTraceGap = board.min_trace_to_trace_clearance ?? traceGap
const viaGap = board.min_via_edge_to_pad_edge_clearance ?? padGap
const maxGap = Math.max(padGap, traceGap, traceTraceGap, viaGap)
const clearanceViolations: { a: string; b: string; layer: string; actual: number; minimum: number }[] = []
const checkedPairs = new Set<string>()
for (const { ids, index } of layerIndices) ids.forEach((id, i) => {
const a = nodes[id]!, box = a.shape.box, aRoot = union.root(String(id))
for (const j of index.search(box.xmin - maxGap, box.ymin - maxGap, box.xmax + maxGap, box.ymax + maxGap)) {
if (j <= i) continue
const b = nodes[ids[j]!]!, bRoot = union.root(String(ids[j]))
const aNet = copperNet(a, aRoot), bNet = copperNet(b, bRoot)
if (aRoot === bRoot || (aNet && aNet === bNet)) continue
const pair = [a.id, b.id].sort().join("|")
if (checkedPairs.has(pair)) continue
const minimum = a.type === "pcb_trace" && b.type === "pcb_trace" ? traceTraceGap
: a.type === "pcb_trace" || b.type === "pcb_trace" ? traceGap
: a.type === "pcb_via" || b.type === "pcb_via" ? viaGap : padGap
const actual = a.shape.distanceTo(b.shape)[0]
if (actual < minimum - EPS) {
clearanceViolations.push({ a: a.id, b: b.id, layer: a.layer, actual, minimum })
checkedPairs.add(pair)
}
}
})
const metricsForNet = (name: string) => {
const group = expected.groups.find(group => group.nets.includes(name))
const roots = new Set(group?.ports.flatMap(port => (portNodes.get(port) ?? []).map(id => union.root(id))))
const ids = new Set(nodes.filter((_, i) => roots.has(union.root(String(i)))).map(node => node.id))
return { traceLengthMm: [...ids].reduce((sum, id) => sum + (segmentLengths.get(id) ?? 0), 0),
viaCount: [...ids].filter(id => viaIds.has(id)).length }
}
const unanchoredCopper = [...new Set(nodes.filter((_, i) => !physicalGroups.has(union.root(String(i)))).map(node => node.id))]
return { requiredPortCount, ignoredNcPortCount, groups, disconnected, shorts, invalid, unanchoredCopper, clearanceViolations,
missingPorts: [...expected.ports.keys()].filter(port => !portNodes.has(port)),
sourceSignature: current.signature, metricsForNet }
}