@tscircuit/fanout-solver 0.0.20 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -239,10 +239,16 @@ samples in parallel, and `--sample-timeout-seconds 600` prevents a difficult
239
239
  sample from blocking the remaining work. Each run writes the full ordered
240
240
  results to `benchmark-results/srj29.json` and
241
241
  `benchmark-results/srj29.md`. A row is marked solved only when every input
242
- connection has a validated breakout, all downstream endpoints remain attached,
243
- and the independent DRC audit passes. Partial solutions are reported as
244
- benchmark results instead of failing the command, making current completion
245
- rates a baseline for solver improvements. `bun run benchmark:srj29` is an alias
242
+ connection has a validated breakout and an independent physical-copper audit
243
+ proves that the emitted wires, vias, and same-net pads connect every original
244
+ endpoint on compatible layers. A second independent audit checks every emitted
245
+ trace and via against different-net pads, obstacles, traces, and vias on every
246
+ physical layer in its span. Reaching an arbitrary boundary point, retaining an
247
+ unrouted endpoint in the output JSON, or emitting copper with a DRC violation
248
+ does not count. The report separates fanout-prefix completion from physically
249
+ connected original connections so partial progress remains visible without
250
+ overstating it as a solution. Partial solutions are reported as benchmark
251
+ results instead of failing the command. `bun run benchmark:srj29` is an alias
246
252
  for the same command.
247
253
 
248
254
  The `SRJ29 Benchmark` GitHub Actions workflow runs the complete dataset on a
package/lib/index.ts CHANGED
@@ -1,7 +1,18 @@
1
1
  export { FanoutSolver } from "./fanout-solver"
2
2
  export { getCopperLayerColor } from "./layer-colors"
3
3
  export { getCopperLayerNames } from "./layer-names"
4
+ export { validateOriginalEndpointConnectivity } from "./validate-original-endpoint-connectivity"
5
+ export { validateRoutedCopperDrc } from "./validate-routed-copper-drc"
4
6
  export { validateFanoutSolution } from "./validate-fanout-solution"
7
+ export type {
8
+ OriginalEndpointConnectivityIssue,
9
+ OriginalEndpointConnectivityReport,
10
+ } from "./validate-original-endpoint-connectivity"
11
+ export type {
12
+ RoutedCopperDrcIssue,
13
+ RoutedCopperDrcIssueCode,
14
+ RoutedCopperDrcReport,
15
+ } from "./validate-routed-copper-drc"
5
16
  export type {
6
17
  Bounds,
7
18
  FanoutAttemptSummary,
@@ -0,0 +1,374 @@
1
+ import type {
2
+ ConnectionPoint,
3
+ Obstacle,
4
+ SimpleRouteConnection,
5
+ SimpleRouteJson,
6
+ SimplifiedPcbTrace,
7
+ } from "@tscircuit/capacity-autorouter"
8
+ import {
9
+ distance,
10
+ distancePointToObstacle,
11
+ distancePointToSegment,
12
+ distanceSegmentToObstacle,
13
+ distanceSegmentToSegment,
14
+ pointIsInsideObstacle,
15
+ } from "./geometry"
16
+ import { getCopperLayerNames, getLayerSpan } from "./layer-names"
17
+ import {
18
+ connectionsShareElectricalNet,
19
+ getConnectionNetKey,
20
+ obstacleSharesElectricalNet,
21
+ } from "./net-identity"
22
+ import type { Point2D, RoutedSegment } from "./types"
23
+
24
+ const EPSILON = 1e-6
25
+
26
+ export interface OriginalEndpointConnectivityIssue {
27
+ code: "original-endpoints-disconnected"
28
+ connectionName: string
29
+ disconnectedEndpointIndices: number[]
30
+ message: string
31
+ }
32
+
33
+ export interface OriginalEndpointConnectivityReport {
34
+ valid: boolean
35
+ checkedConnectionCount: number
36
+ connectedConnectionCount: number
37
+ checkedEndpointCount: number
38
+ connectedEndpointCount: number
39
+ issues: OriginalEndpointConnectivityIssue[]
40
+ }
41
+
42
+ interface EndpointCopper {
43
+ type: "endpoint"
44
+ connectionName: string
45
+ endpointIndex: number
46
+ point: ConnectionPoint
47
+ layers: string[]
48
+ }
49
+
50
+ interface ObstacleCopper {
51
+ type: "obstacle"
52
+ obstacle: Obstacle
53
+ }
54
+
55
+ interface SegmentCopper {
56
+ type: "segment"
57
+ segment: RoutedSegment
58
+ }
59
+
60
+ interface ViaCopper {
61
+ type: "via"
62
+ center: Point2D
63
+ diameter: number
64
+ layers: string[]
65
+ }
66
+
67
+ type CopperPrimitive =
68
+ | EndpointCopper
69
+ | ObstacleCopper
70
+ | SegmentCopper
71
+ | ViaCopper
72
+
73
+ function getPointLayers(point: ConnectionPoint): string[] {
74
+ return "layer" in point ? [point.layer] : point.layers
75
+ }
76
+
77
+ function layersOverlap(first: readonly string[], second: readonly string[]) {
78
+ return first.some((layer) => second.includes(layer))
79
+ }
80
+
81
+ function extractTraceCopper(
82
+ trace: SimplifiedPcbTrace,
83
+ layerNames: string[],
84
+ ): Array<SegmentCopper | ViaCopper> {
85
+ const copper: Array<SegmentCopper | ViaCopper> = []
86
+ let previousWire:
87
+ | Extract<SimplifiedPcbTrace["route"][number], { route_type: "wire" }>
88
+ | undefined
89
+
90
+ for (const routePoint of trace.route) {
91
+ if (routePoint.route_type === "via") {
92
+ copper.push({
93
+ type: "via",
94
+ center: { x: routePoint.x, y: routePoint.y },
95
+ diameter: routePoint.via_diameter ?? 0,
96
+ layers: getLayerSpan(
97
+ routePoint.from_layer,
98
+ routePoint.to_layer,
99
+ layerNames,
100
+ ),
101
+ })
102
+ previousWire = undefined
103
+ continue
104
+ }
105
+ if (routePoint.route_type !== "wire") continue
106
+ if (
107
+ previousWire &&
108
+ previousWire.layer === routePoint.layer &&
109
+ distance(previousWire, routePoint) > EPSILON
110
+ ) {
111
+ copper.push({
112
+ type: "segment",
113
+ segment: {
114
+ start: { x: previousWire.x, y: previousWire.y },
115
+ end: { x: routePoint.x, y: routePoint.y },
116
+ width: routePoint.width,
117
+ layer: routePoint.layer,
118
+ },
119
+ })
120
+ }
121
+ previousWire = routePoint
122
+ }
123
+ return copper
124
+ }
125
+
126
+ function primitivesTouch(first: CopperPrimitive, second: CopperPrimitive) {
127
+ if (first.type === "endpoint" && second.type === "endpoint") {
128
+ return (
129
+ layersOverlap(first.layers, second.layers) &&
130
+ distance(first.point, second.point) <= EPSILON
131
+ )
132
+ }
133
+ if (first.type === "endpoint" && second.type === "obstacle") {
134
+ return (
135
+ layersOverlap(first.layers, second.obstacle.layers) &&
136
+ pointIsInsideObstacle(first.point, second.obstacle, EPSILON)
137
+ )
138
+ }
139
+ if (first.type === "obstacle" && second.type === "endpoint") {
140
+ return primitivesTouch(second, first)
141
+ }
142
+ if (first.type === "endpoint" && second.type === "segment") {
143
+ return (
144
+ first.layers.includes(second.segment.layer) &&
145
+ distancePointToSegment(
146
+ first.point,
147
+ second.segment.start,
148
+ second.segment.end,
149
+ ) <=
150
+ second.segment.width / 2 + EPSILON
151
+ )
152
+ }
153
+ if (first.type === "segment" && second.type === "endpoint") {
154
+ return primitivesTouch(second, first)
155
+ }
156
+ if (first.type === "endpoint" && second.type === "via") {
157
+ return (
158
+ layersOverlap(first.layers, second.layers) &&
159
+ distance(first.point, second.center) <= second.diameter / 2 + EPSILON
160
+ )
161
+ }
162
+ if (first.type === "via" && second.type === "endpoint") {
163
+ return primitivesTouch(second, first)
164
+ }
165
+ if (first.type === "obstacle" && second.type === "segment") {
166
+ return (
167
+ first.obstacle.layers.includes(second.segment.layer) &&
168
+ distanceSegmentToObstacle(second.segment, first.obstacle) <=
169
+ second.segment.width / 2 + EPSILON
170
+ )
171
+ }
172
+ if (first.type === "segment" && second.type === "obstacle") {
173
+ return primitivesTouch(second, first)
174
+ }
175
+ if (first.type === "obstacle" && second.type === "via") {
176
+ return (
177
+ layersOverlap(first.obstacle.layers, second.layers) &&
178
+ distancePointToObstacle(second.center, first.obstacle) <=
179
+ second.diameter / 2 + EPSILON
180
+ )
181
+ }
182
+ if (first.type === "via" && second.type === "obstacle") {
183
+ return primitivesTouch(second, first)
184
+ }
185
+ if (first.type === "segment" && second.type === "segment") {
186
+ return (
187
+ first.segment.layer === second.segment.layer &&
188
+ distanceSegmentToSegment(
189
+ first.segment.start,
190
+ first.segment.end,
191
+ second.segment.start,
192
+ second.segment.end,
193
+ ) <=
194
+ (first.segment.width + second.segment.width) / 2 + EPSILON
195
+ )
196
+ }
197
+ if (first.type === "segment" && second.type === "via") {
198
+ return (
199
+ second.layers.includes(first.segment.layer) &&
200
+ distancePointToSegment(
201
+ second.center,
202
+ first.segment.start,
203
+ first.segment.end,
204
+ ) <=
205
+ second.diameter / 2 + first.segment.width / 2 + EPSILON
206
+ )
207
+ }
208
+ if (first.type === "via" && second.type === "segment") {
209
+ return primitivesTouch(second, first)
210
+ }
211
+ if (first.type === "via" && second.type === "via") {
212
+ return (
213
+ layersOverlap(first.layers, second.layers) &&
214
+ distance(first.center, second.center) <=
215
+ (first.diameter + second.diameter) / 2 + EPSILON
216
+ )
217
+ }
218
+ return false
219
+ }
220
+
221
+ class DisjointSet {
222
+ private readonly parent: number[]
223
+
224
+ constructor(size: number) {
225
+ this.parent = Array.from({ length: size }, (_, index) => index)
226
+ }
227
+
228
+ find(index: number): number {
229
+ const parent = this.parent[index]!
230
+ if (parent === index) return index
231
+ const root = this.find(parent)
232
+ this.parent[index] = root
233
+ return root
234
+ }
235
+
236
+ union(first: number, second: number): void {
237
+ const firstRoot = this.find(first)
238
+ const secondRoot = this.find(second)
239
+ if (firstRoot !== secondRoot) this.parent[secondRoot] = firstRoot
240
+ }
241
+ }
242
+
243
+ function traceBelongsToNet(
244
+ inputSrj: SimpleRouteJson,
245
+ trace: SimplifiedPcbTrace,
246
+ representativeConnection: SimpleRouteConnection,
247
+ ): boolean {
248
+ return Boolean(
249
+ trace.connection_name &&
250
+ connectionsShareElectricalNet(
251
+ inputSrj,
252
+ trace.connection_name,
253
+ representativeConnection.name,
254
+ ),
255
+ )
256
+ }
257
+
258
+ /**
259
+ * Independently proves that emitted copper connects every original SRJ
260
+ * endpoint. Merely reaching a boundary or retaining an endpoint in the output
261
+ * connection metadata does not count as connectivity.
262
+ */
263
+ export function validateOriginalEndpointConnectivity(params: {
264
+ inputSrj: SimpleRouteJson
265
+ routedSrj: SimpleRouteJson
266
+ }): OriginalEndpointConnectivityReport {
267
+ const { inputSrj, routedSrj } = params
268
+ const layerNames = getCopperLayerNames(routedSrj.layerCount)
269
+ const connectionsByNet = new Map<string, SimpleRouteConnection[]>()
270
+ for (const connection of inputSrj.connections) {
271
+ const netKey = getConnectionNetKey(connection)
272
+ const connections = connectionsByNet.get(netKey) ?? []
273
+ connections.push(connection)
274
+ connectionsByNet.set(netKey, connections)
275
+ }
276
+
277
+ const issues: OriginalEndpointConnectivityIssue[] = []
278
+ let connectedConnectionCount = 0
279
+ let connectedEndpointCount = 0
280
+
281
+ for (const connections of connectionsByNet.values()) {
282
+ const representativeConnection = connections[0]!
283
+ const endpointCopper: EndpointCopper[] = connections.flatMap((connection) =>
284
+ connection.pointsToConnect.map((point, endpointIndex) => ({
285
+ type: "endpoint" as const,
286
+ connectionName: connection.name,
287
+ endpointIndex,
288
+ point,
289
+ layers: getPointLayers(point),
290
+ })),
291
+ )
292
+ const obstacleCopper: ObstacleCopper[] = inputSrj.obstacles
293
+ .filter((obstacle) =>
294
+ obstacleSharesElectricalNet(
295
+ inputSrj,
296
+ obstacle,
297
+ representativeConnection.name,
298
+ ),
299
+ )
300
+ .map((obstacle) => ({ type: "obstacle", obstacle }))
301
+ const routeCopper = (routedSrj.traces ?? [])
302
+ .filter((trace) =>
303
+ traceBelongsToNet(inputSrj, trace, representativeConnection),
304
+ )
305
+ .flatMap((trace) => extractTraceCopper(trace, layerNames))
306
+ const copper: CopperPrimitive[] = [
307
+ ...endpointCopper,
308
+ ...obstacleCopper,
309
+ ...routeCopper,
310
+ ]
311
+ const connectedCopper = new DisjointSet(copper.length)
312
+ for (let firstIndex = 0; firstIndex < copper.length; firstIndex++) {
313
+ for (
314
+ let secondIndex = firstIndex + 1;
315
+ secondIndex < copper.length;
316
+ secondIndex++
317
+ ) {
318
+ if (primitivesTouch(copper[firstIndex]!, copper[secondIndex]!)) {
319
+ connectedCopper.union(firstIndex, secondIndex)
320
+ }
321
+ }
322
+ }
323
+
324
+ const endpointIndexByConnection = new Map<string, number[]>()
325
+ for (let index = 0; index < endpointCopper.length; index++) {
326
+ const endpoint = endpointCopper[index]!
327
+ const indices =
328
+ endpointIndexByConnection.get(endpoint.connectionName) ?? []
329
+ indices[endpoint.endpointIndex] = index
330
+ endpointIndexByConnection.set(endpoint.connectionName, indices)
331
+ }
332
+ for (const connection of connections) {
333
+ const endpointIndices =
334
+ endpointIndexByConnection.get(connection.name) ?? []
335
+ const firstEndpointIndex = endpointIndices[0]
336
+ const firstRoot =
337
+ firstEndpointIndex === undefined
338
+ ? undefined
339
+ : connectedCopper.find(firstEndpointIndex)
340
+ const disconnectedEndpointIndices = endpointIndices.flatMap(
341
+ (endpointIndex, index) =>
342
+ firstRoot === undefined ||
343
+ connectedCopper.find(endpointIndex) !== firstRoot
344
+ ? [index]
345
+ : [],
346
+ )
347
+ connectedEndpointCount +=
348
+ endpointIndices.length - disconnectedEndpointIndices.length
349
+ if (disconnectedEndpointIndices.length === 0) {
350
+ connectedConnectionCount++
351
+ } else {
352
+ issues.push({
353
+ code: "original-endpoints-disconnected",
354
+ connectionName: connection.name,
355
+ disconnectedEndpointIndices,
356
+ message: `Connection ${connection.name} has no physical copper path from endpoint 0 to original endpoint${disconnectedEndpointIndices.length === 1 ? "" : "s"} ${disconnectedEndpointIndices.join(", ")}`,
357
+ })
358
+ }
359
+ }
360
+ }
361
+
362
+ const checkedEndpointCount = inputSrj.connections.reduce(
363
+ (count, connection) => count + connection.pointsToConnect.length,
364
+ 0,
365
+ )
366
+ return {
367
+ valid: issues.length === 0,
368
+ checkedConnectionCount: inputSrj.connections.length,
369
+ connectedConnectionCount,
370
+ checkedEndpointCount,
371
+ connectedEndpointCount,
372
+ issues,
373
+ }
374
+ }
@@ -0,0 +1,442 @@
1
+ import type {
2
+ SimpleRouteJson,
3
+ SimplifiedPcbTrace,
4
+ } from "@tscircuit/capacity-autorouter"
5
+ import {
6
+ distance,
7
+ distancePointToObstacle,
8
+ distancePointToSegment,
9
+ distanceSegmentToObstacle,
10
+ pointIsInsideObstacle,
11
+ segmentsAreClear,
12
+ } from "./geometry"
13
+ import { getCopperLayerNames, getLayerSpan } from "./layer-names"
14
+ import {
15
+ connectionsShareElectricalNet,
16
+ obstacleSharesElectricalNet,
17
+ } from "./net-identity"
18
+ import type { Point2D, RoutedSegment } from "./types"
19
+
20
+ const EPSILON = 1e-6
21
+
22
+ export type RoutedCopperDrcIssueCode =
23
+ | "unknown-trace-connection"
24
+ | "unsupported-route-point"
25
+ | "disconnected-trace"
26
+ | "trace-obstacle-clearance"
27
+ | "via-obstacle-clearance"
28
+ | "different-net-trace-clearance"
29
+ | "different-net-trace-via-clearance"
30
+ | "different-net-via-clearance"
31
+
32
+ export interface RoutedCopperDrcIssue {
33
+ code: RoutedCopperDrcIssueCode
34
+ message: string
35
+ traceId: string
36
+ connectionName?: string
37
+ otherTraceId?: string
38
+ otherConnectionName?: string
39
+ layer?: string
40
+ obstacleId?: string
41
+ }
42
+
43
+ export interface RoutedCopperDrcReport {
44
+ valid: boolean
45
+ checkedTraceCount: number
46
+ checkedSegmentCount: number
47
+ checkedViaCount: number
48
+ issues: RoutedCopperDrcIssue[]
49
+ }
50
+
51
+ interface RoutedVia {
52
+ center: Point2D
53
+ diameter: number
54
+ spanLayers: string[]
55
+ }
56
+
57
+ interface TraceCopper {
58
+ trace: SimplifiedPcbTrace
59
+ connectionName: string
60
+ segments: RoutedSegment[]
61
+ vias: RoutedVia[]
62
+ }
63
+
64
+ function isLegalTerminalBodyEscape(params: {
65
+ inputSrj: SimpleRouteJson
66
+ segment: RoutedSegment
67
+ bodyObstacle: SimpleRouteJson["obstacles"][number]
68
+ connectionName: string
69
+ }): boolean {
70
+ const { inputSrj, segment, bodyObstacle, connectionName } = params
71
+ if (bodyObstacle.connectedTo.length > 0 || !bodyObstacle.componentId) {
72
+ return false
73
+ }
74
+ const sameNetComponentPads = inputSrj.obstacles.filter(
75
+ (obstacle) =>
76
+ obstacle.componentId === bodyObstacle.componentId &&
77
+ obstacle.layers.includes(segment.layer) &&
78
+ obstacleSharesElectricalNet(inputSrj, obstacle, connectionName),
79
+ )
80
+ for (const pad of sameNetComponentPads) {
81
+ for (const [terminal, other] of [
82
+ [segment.start, segment.end],
83
+ [segment.end, segment.start],
84
+ ] as const) {
85
+ if (!pointIsInsideObstacle(terminal, pad, EPSILON)) continue
86
+ const outwardFromBody = {
87
+ x: terminal.x - bodyObstacle.center.x,
88
+ y: terminal.y - bodyObstacle.center.y,
89
+ }
90
+ const terminalToOther = {
91
+ x: other.x - terminal.x,
92
+ y: other.y - terminal.y,
93
+ }
94
+ if (
95
+ outwardFromBody.x * terminalToOther.x +
96
+ outwardFromBody.y * terminalToOther.y >
97
+ EPSILON
98
+ ) {
99
+ return true
100
+ }
101
+ }
102
+ }
103
+ return false
104
+ }
105
+
106
+ function pointsMatch(first: Point2D, second: Point2D): boolean {
107
+ return distance(first, second) <= EPSILON
108
+ }
109
+
110
+ function addIssue(
111
+ issues: RoutedCopperDrcIssue[],
112
+ issue: RoutedCopperDrcIssue,
113
+ ): void {
114
+ issues.push(issue)
115
+ }
116
+
117
+ function extractTraceCopper(params: {
118
+ srj: SimpleRouteJson
119
+ trace: SimplifiedPcbTrace
120
+ connectionName: string
121
+ layerNames: string[]
122
+ issues: RoutedCopperDrcIssue[]
123
+ }): TraceCopper {
124
+ const { srj, trace, connectionName, layerNames, issues } = params
125
+ const segments: RoutedSegment[] = []
126
+ const vias: RoutedVia[] = []
127
+ let previousWire:
128
+ | Extract<SimplifiedPcbTrace["route"][number], { route_type: "wire" }>
129
+ | undefined
130
+ let pendingVia:
131
+ | Extract<SimplifiedPcbTrace["route"][number], { route_type: "via" }>
132
+ | undefined
133
+
134
+ for (const routePoint of trace.route) {
135
+ if (routePoint.route_type === "via") {
136
+ const spanLayers = getLayerSpan(
137
+ routePoint.from_layer,
138
+ routePoint.to_layer,
139
+ layerNames,
140
+ )
141
+ vias.push({
142
+ center: { x: routePoint.x, y: routePoint.y },
143
+ diameter:
144
+ routePoint.via_diameter ?? srj.minViaPadDiameter ?? srj.minTraceWidth,
145
+ spanLayers,
146
+ })
147
+ if (
148
+ !previousWire ||
149
+ !pointsMatch(previousWire, routePoint) ||
150
+ previousWire.layer !== routePoint.from_layer
151
+ ) {
152
+ addIssue(issues, {
153
+ code: "disconnected-trace",
154
+ traceId: trace.pcb_trace_id,
155
+ connectionName,
156
+ message: `Trace ${trace.pcb_trace_id} reaches a via without a matching ${routePoint.from_layer} wire endpoint`,
157
+ })
158
+ }
159
+ pendingVia = routePoint
160
+ continue
161
+ }
162
+
163
+ if (routePoint.route_type !== "wire") {
164
+ addIssue(issues, {
165
+ code: "unsupported-route-point",
166
+ traceId: trace.pcb_trace_id,
167
+ connectionName,
168
+ message: `Trace ${trace.pcb_trace_id} contains unsupported ${routePoint.route_type} geometry`,
169
+ })
170
+ previousWire = undefined
171
+ pendingVia = undefined
172
+ continue
173
+ }
174
+
175
+ if (pendingVia) {
176
+ if (
177
+ !pointsMatch(routePoint, pendingVia) ||
178
+ routePoint.layer !== pendingVia.to_layer
179
+ ) {
180
+ addIssue(issues, {
181
+ code: "disconnected-trace",
182
+ traceId: trace.pcb_trace_id,
183
+ connectionName,
184
+ message: `Trace ${trace.pcb_trace_id} does not continue from its via on ${pendingVia.to_layer}`,
185
+ })
186
+ }
187
+ previousWire = routePoint
188
+ pendingVia = undefined
189
+ continue
190
+ }
191
+
192
+ if (previousWire) {
193
+ if (previousWire.layer !== routePoint.layer) {
194
+ addIssue(issues, {
195
+ code: "disconnected-trace",
196
+ traceId: trace.pcb_trace_id,
197
+ connectionName,
198
+ message: `Trace ${trace.pcb_trace_id} changes from ${previousWire.layer} to ${routePoint.layer} without a via`,
199
+ })
200
+ } else if (!pointsMatch(previousWire, routePoint)) {
201
+ segments.push({
202
+ start: { x: previousWire.x, y: previousWire.y },
203
+ end: { x: routePoint.x, y: routePoint.y },
204
+ width: routePoint.width,
205
+ layer: routePoint.layer,
206
+ })
207
+ }
208
+ }
209
+ previousWire = routePoint
210
+ }
211
+
212
+ if (pendingVia) {
213
+ addIssue(issues, {
214
+ code: "disconnected-trace",
215
+ traceId: trace.pcb_trace_id,
216
+ connectionName,
217
+ message: `Trace ${trace.pcb_trace_id} ends at a via without a wire on ${pendingVia.to_layer}`,
218
+ })
219
+ }
220
+
221
+ return { trace, connectionName, segments, vias }
222
+ }
223
+
224
+ /**
225
+ * Independently audits every emitted trace and via against the original SRJ
226
+ * obstacle field and every other emitted net. This deliberately does not rely
227
+ * on the solver's accepted route-plan objects.
228
+ */
229
+ export function validateRoutedCopperDrc(params: {
230
+ inputSrj: SimpleRouteJson
231
+ routedSrj: SimpleRouteJson
232
+ clearance: number
233
+ }): RoutedCopperDrcReport {
234
+ const { inputSrj, routedSrj, clearance } = params
235
+ const issues: RoutedCopperDrcIssue[] = []
236
+ const layerNames = getCopperLayerNames(routedSrj.layerCount)
237
+ const traceCopper: TraceCopper[] = []
238
+
239
+ for (const trace of routedSrj.traces ?? []) {
240
+ const connectionName = trace.connection_name
241
+ if (
242
+ !connectionName ||
243
+ !inputSrj.connections.some(
244
+ (connection) => connection.name === connectionName,
245
+ )
246
+ ) {
247
+ addIssue(issues, {
248
+ code: "unknown-trace-connection",
249
+ traceId: trace.pcb_trace_id,
250
+ ...(connectionName ? { connectionName } : {}),
251
+ message: `Trace ${trace.pcb_trace_id} does not identify an input connection`,
252
+ })
253
+ continue
254
+ }
255
+ traceCopper.push(
256
+ extractTraceCopper({
257
+ srj: routedSrj,
258
+ trace,
259
+ connectionName,
260
+ layerNames,
261
+ issues,
262
+ }),
263
+ )
264
+ }
265
+
266
+ for (const copper of traceCopper) {
267
+ for (const segment of copper.segments) {
268
+ for (const obstacle of inputSrj.obstacles) {
269
+ if (
270
+ !obstacle.layers.includes(segment.layer) ||
271
+ obstacleSharesElectricalNet(inputSrj, obstacle, copper.connectionName)
272
+ ) {
273
+ continue
274
+ }
275
+ if (
276
+ isLegalTerminalBodyEscape({
277
+ inputSrj,
278
+ segment,
279
+ bodyObstacle: obstacle,
280
+ connectionName: copper.connectionName,
281
+ })
282
+ ) {
283
+ continue
284
+ }
285
+ const actual = distanceSegmentToObstacle(segment, obstacle)
286
+ const required = segment.width / 2 + clearance
287
+ if (actual < required - EPSILON) {
288
+ addIssue(issues, {
289
+ code: "trace-obstacle-clearance",
290
+ traceId: copper.trace.pcb_trace_id,
291
+ connectionName: copper.connectionName,
292
+ layer: segment.layer,
293
+ obstacleId: obstacle.obstacleId,
294
+ message: `Trace ${copper.trace.pcb_trace_id} on ${segment.layer} is ${actual.toFixed(4)}mm from different-net obstacle ${obstacle.obstacleId}; ${required.toFixed(4)}mm is required`,
295
+ })
296
+ }
297
+ }
298
+ }
299
+ for (const via of copper.vias) {
300
+ for (const obstacle of inputSrj.obstacles) {
301
+ if (
302
+ !obstacle.layers.some((layer) => via.spanLayers.includes(layer)) ||
303
+ obstacleSharesElectricalNet(inputSrj, obstacle, copper.connectionName)
304
+ ) {
305
+ continue
306
+ }
307
+ const actual = distancePointToObstacle(via.center, obstacle)
308
+ const required = via.diameter / 2 + clearance
309
+ if (actual < required - EPSILON) {
310
+ addIssue(issues, {
311
+ code: "via-obstacle-clearance",
312
+ traceId: copper.trace.pcb_trace_id,
313
+ connectionName: copper.connectionName,
314
+ obstacleId: obstacle.obstacleId,
315
+ message: `Via in ${copper.trace.pcb_trace_id} is ${actual.toFixed(4)}mm from different-net obstacle ${obstacle.obstacleId} on its layer span; ${required.toFixed(4)}mm is required`,
316
+ })
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ for (let firstIndex = 0; firstIndex < traceCopper.length; firstIndex++) {
323
+ const first = traceCopper[firstIndex]!
324
+ for (
325
+ let secondIndex = firstIndex + 1;
326
+ secondIndex < traceCopper.length;
327
+ secondIndex++
328
+ ) {
329
+ const second = traceCopper[secondIndex]!
330
+ if (
331
+ connectionsShareElectricalNet(
332
+ inputSrj,
333
+ first.connectionName,
334
+ second.connectionName,
335
+ )
336
+ ) {
337
+ continue
338
+ }
339
+
340
+ for (const firstSegment of first.segments) {
341
+ for (const secondSegment of second.segments) {
342
+ if (segmentsAreClear(firstSegment, secondSegment, clearance)) continue
343
+ addIssue(issues, {
344
+ code: "different-net-trace-clearance",
345
+ traceId: first.trace.pcb_trace_id,
346
+ connectionName: first.connectionName,
347
+ otherTraceId: second.trace.pcb_trace_id,
348
+ otherConnectionName: second.connectionName,
349
+ layer: firstSegment.layer,
350
+ message: `Different-net traces ${first.trace.pcb_trace_id} and ${second.trace.pcb_trace_id} intersect or violate clearance on ${firstSegment.layer}`,
351
+ })
352
+ }
353
+ for (const secondVia of second.vias) {
354
+ if (
355
+ !secondVia.spanLayers.includes(firstSegment.layer) ||
356
+ distancePointToSegment(
357
+ secondVia.center,
358
+ firstSegment.start,
359
+ firstSegment.end,
360
+ ) >=
361
+ secondVia.diameter / 2 +
362
+ firstSegment.width / 2 +
363
+ clearance -
364
+ EPSILON
365
+ ) {
366
+ continue
367
+ }
368
+ addIssue(issues, {
369
+ code: "different-net-trace-via-clearance",
370
+ traceId: first.trace.pcb_trace_id,
371
+ connectionName: first.connectionName,
372
+ otherTraceId: second.trace.pcb_trace_id,
373
+ otherConnectionName: second.connectionName,
374
+ layer: firstSegment.layer,
375
+ message: `Trace ${first.trace.pcb_trace_id} violates via clearance to ${second.trace.pcb_trace_id} on ${firstSegment.layer}`,
376
+ })
377
+ }
378
+ }
379
+
380
+ for (const firstVia of first.vias) {
381
+ for (const secondSegment of second.segments) {
382
+ if (
383
+ !firstVia.spanLayers.includes(secondSegment.layer) ||
384
+ distancePointToSegment(
385
+ firstVia.center,
386
+ secondSegment.start,
387
+ secondSegment.end,
388
+ ) >=
389
+ firstVia.diameter / 2 +
390
+ secondSegment.width / 2 +
391
+ clearance -
392
+ EPSILON
393
+ ) {
394
+ continue
395
+ }
396
+ addIssue(issues, {
397
+ code: "different-net-trace-via-clearance",
398
+ traceId: first.trace.pcb_trace_id,
399
+ connectionName: first.connectionName,
400
+ otherTraceId: second.trace.pcb_trace_id,
401
+ otherConnectionName: second.connectionName,
402
+ layer: secondSegment.layer,
403
+ message: `Via in ${first.trace.pcb_trace_id} violates trace clearance to ${second.trace.pcb_trace_id} on ${secondSegment.layer}`,
404
+ })
405
+ }
406
+ for (const secondVia of second.vias) {
407
+ if (
408
+ !firstVia.spanLayers.some((layer) =>
409
+ secondVia.spanLayers.includes(layer),
410
+ ) ||
411
+ distance(firstVia.center, secondVia.center) >=
412
+ (firstVia.diameter + secondVia.diameter) / 2 + clearance - EPSILON
413
+ ) {
414
+ continue
415
+ }
416
+ addIssue(issues, {
417
+ code: "different-net-via-clearance",
418
+ traceId: first.trace.pcb_trace_id,
419
+ connectionName: first.connectionName,
420
+ otherTraceId: second.trace.pcb_trace_id,
421
+ otherConnectionName: second.connectionName,
422
+ message: `Different-net vias in ${first.trace.pcb_trace_id} and ${second.trace.pcb_trace_id} violate clearance on an overlapping layer span`,
423
+ })
424
+ }
425
+ }
426
+ }
427
+ }
428
+
429
+ return {
430
+ valid: issues.length === 0,
431
+ checkedTraceCount: routedSrj.traces?.length ?? 0,
432
+ checkedSegmentCount: traceCopper.reduce(
433
+ (count, copper) => count + copper.segments.length,
434
+ 0,
435
+ ),
436
+ checkedViaCount: traceCopper.reduce(
437
+ (count, copper) => count + copper.vias.length,
438
+ 0,
439
+ ),
440
+ issues,
441
+ }
442
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",