@tscircuit/fanout-solver 0.0.10

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.
@@ -0,0 +1,617 @@
1
+ import {
2
+ convertSrjToGraphicsObject,
3
+ type SimpleRouteJson,
4
+ } from "@tscircuit/capacity-autorouter"
5
+ import { BaseSolver } from "@tscircuit/solver-utils"
6
+ import type { GraphicsObject } from "graphics-debug"
7
+ import { buildOutputSimpleRouteJson } from "./build-output"
8
+ import { distanceSegmentToObstacle } from "./geometry"
9
+ import { getCopperLayerColor } from "./layer-colors"
10
+ import { generateLayerAssignments, getCopperLayerNames } from "./layer-names"
11
+ import { prepareFanoutBuses } from "./prepare-buses"
12
+ import { routeBus } from "./route-bus"
13
+ import { routeSingleLayerWithAdaptiveExits } from "./route-single-layer-adaptive-exits"
14
+ import { routeSingleLayerWithPushAndShove } from "./route-single-layer-push-shove"
15
+ import type {
16
+ AssignmentAttempt,
17
+ FanoutAttemptSummary,
18
+ FanoutBorderDistribution,
19
+ FanoutSolverOptions,
20
+ FanoutSolverOutput,
21
+ PreparedBus,
22
+ } from "./types"
23
+
24
+ interface ResolvedFanoutConfig {
25
+ traceWidth: number
26
+ viaDiameter: number
27
+ viaHoleDiameter: number
28
+ clearance: number
29
+ compactBusTracks: boolean
30
+ singleLayerPushAndShove: boolean
31
+ singleLayerAdaptiveExits: boolean
32
+ borderDistribution: FanoutBorderDistribution
33
+ layerNames: string[]
34
+ escapeLayers: string[]
35
+ maxLayerCombinations: number
36
+ }
37
+
38
+ function resolvePositiveNumber(label: string, value: number): number {
39
+ if (!Number.isFinite(value) || value <= 0) {
40
+ throw new Error(
41
+ `FanoutSolver: ${label} must be a positive number, received ${value}`,
42
+ )
43
+ }
44
+ return value
45
+ }
46
+
47
+ function resolveConfig(
48
+ srj: SimpleRouteJson,
49
+ options: FanoutSolverOptions,
50
+ ): ResolvedFanoutConfig {
51
+ const traceWidth = resolvePositiveNumber(
52
+ "traceWidth",
53
+ options.traceWidth ?? srj.nominalTraceWidth ?? srj.minTraceWidth,
54
+ )
55
+ const viaDiameter = resolvePositiveNumber(
56
+ "viaDiameter",
57
+ options.viaDiameter ??
58
+ srj.minViaPadDiameter ??
59
+ srj.min_via_pad_diameter ??
60
+ srj.minViaDiameter ??
61
+ Math.max(traceWidth * 2, 0.3),
62
+ )
63
+ const viaHoleDiameter = resolvePositiveNumber(
64
+ "viaHoleDiameter",
65
+ options.viaHoleDiameter ??
66
+ srj.minViaHoleDiameter ??
67
+ srj.min_via_hole_diameter ??
68
+ viaDiameter * 0.5,
69
+ )
70
+ if (viaHoleDiameter >= viaDiameter) {
71
+ throw new Error(
72
+ `FanoutSolver: viaHoleDiameter ${viaHoleDiameter} must be smaller than viaDiameter ${viaDiameter}`,
73
+ )
74
+ }
75
+ const clearance = resolvePositiveNumber(
76
+ "clearance",
77
+ options.clearance ??
78
+ srj.minViaEdgeToPadEdgeClearance ??
79
+ srj.minTraceToPadEdgeClearance ??
80
+ srj.defaultObstacleMargin ??
81
+ srj.minTraceWidth,
82
+ )
83
+ const layerNames = getCopperLayerNames(srj.layerCount)
84
+ const escapeLayers = options.escapeLayers ?? layerNames
85
+ for (const layer of escapeLayers) {
86
+ if (!layerNames.includes(layer)) {
87
+ throw new Error(
88
+ `FanoutSolver: escape layer "${layer}" is not available in a ${srj.layerCount}-layer SimpleRouteJson`,
89
+ )
90
+ }
91
+ }
92
+ if (new Set(escapeLayers).size !== escapeLayers.length) {
93
+ throw new Error("FanoutSolver: escapeLayers contains duplicates")
94
+ }
95
+ const borderDistribution = options.borderDistribution ?? "preserve"
96
+ if (borderDistribution !== "preserve" && borderDistribution !== "even") {
97
+ throw new Error(
98
+ `FanoutSolver: borderDistribution must be "preserve" or "even", received "${borderDistribution}"`,
99
+ )
100
+ }
101
+
102
+ return {
103
+ traceWidth,
104
+ viaDiameter,
105
+ viaHoleDiameter,
106
+ clearance,
107
+ compactBusTracks: options.compactBusTracks ?? false,
108
+ singleLayerPushAndShove: options.singleLayerPushAndShove ?? false,
109
+ singleLayerAdaptiveExits: options.singleLayerAdaptiveExits ?? false,
110
+ borderDistribution,
111
+ layerNames,
112
+ escapeLayers,
113
+ maxLayerCombinations:
114
+ options.maxLayerCombinations === undefined
115
+ ? 256
116
+ : resolvePositiveNumber(
117
+ "maxLayerCombinations",
118
+ options.maxLayerCombinations,
119
+ ),
120
+ }
121
+ }
122
+
123
+ function assignmentLoadPenalty(
124
+ assignment: Readonly<Record<string, string>>,
125
+ ): number {
126
+ const loadByLayer = new Map<string, number>()
127
+ for (const layer of Object.values(assignment)) {
128
+ loadByLayer.set(layer, (loadByLayer.get(layer) ?? 0) + 1)
129
+ }
130
+ return [...loadByLayer.values()].reduce(
131
+ (penalty, load) => penalty + load * load,
132
+ 0,
133
+ )
134
+ }
135
+
136
+ function getBusDistanceToBoundary(bus: PreparedBus): number {
137
+ const averageSource =
138
+ bus.connections.reduce((sum, connection) => {
139
+ const sourceAxis =
140
+ bus.direction === "left" || bus.direction === "right"
141
+ ? connection.sourcePoint.x
142
+ : connection.sourcePoint.y
143
+ return sum + sourceAxis
144
+ }, 0) / bus.connections.length
145
+ switch (bus.direction) {
146
+ case "right":
147
+ return bus.sharedBoundary.maxX - averageSource
148
+ case "left":
149
+ return averageSource - bus.sharedBoundary.minX
150
+ case "up":
151
+ return bus.sharedBoundary.maxY - averageSource
152
+ case "down":
153
+ return averageSource - bus.sharedBoundary.minY
154
+ }
155
+ }
156
+
157
+ function busIsOnOutwardComponentEdge(bus: PreparedBus): boolean {
158
+ const isHorizontal = bus.direction === "left" || bus.direction === "right"
159
+ const directionalCoordinates = isHorizontal
160
+ ? bus.xCoordinates
161
+ : bus.yCoordinates
162
+ const averageSource =
163
+ bus.connections.reduce(
164
+ (sum, connection) =>
165
+ sum +
166
+ (isHorizontal ? connection.sourcePoint.x : connection.sourcePoint.y),
167
+ 0,
168
+ ) / bus.connections.length
169
+ const outwardCoordinate =
170
+ bus.direction === "right" || bus.direction === "up"
171
+ ? Math.max(...directionalCoordinates)
172
+ : Math.min(...directionalCoordinates)
173
+ return Math.abs(averageSource - outwardCoordinate) < 1e-6
174
+ }
175
+
176
+ function getBusDepthInRows(bus: PreparedBus): number {
177
+ const isHorizontal = bus.direction === "left" || bus.direction === "right"
178
+ const directionalCoordinates = isHorizontal
179
+ ? bus.xCoordinates
180
+ : bus.yCoordinates
181
+ const averageSource =
182
+ bus.connections.reduce(
183
+ (sum, connection) =>
184
+ sum +
185
+ (isHorizontal ? connection.sourcePoint.x : connection.sourcePoint.y),
186
+ 0,
187
+ ) / bus.connections.length
188
+ const outwardCoordinate =
189
+ bus.direction === "right" || bus.direction === "up"
190
+ ? Math.max(...directionalCoordinates)
191
+ : Math.min(...directionalCoordinates)
192
+ const directionalPitch = isHorizontal ? bus.pitchX : bus.pitchY
193
+
194
+ return Math.round(
195
+ Math.abs(averageSource - outwardCoordinate) / directionalPitch,
196
+ )
197
+ }
198
+
199
+ function sourceLayerEscapeIsBlocked(params: {
200
+ bus: PreparedBus
201
+ srj: SimpleRouteJson
202
+ traceWidth: number
203
+ clearance: number
204
+ }): boolean {
205
+ const { bus, srj, traceWidth, clearance } = params
206
+ for (const connection of bus.connections) {
207
+ const source = {
208
+ x: connection.sourcePoint.x,
209
+ y: connection.sourcePoint.y,
210
+ }
211
+ const boundaryPoint = (() => {
212
+ switch (bus.direction) {
213
+ case "left":
214
+ return { x: bus.sharedBoundary.minX, y: source.y }
215
+ case "right":
216
+ return { x: bus.sharedBoundary.maxX, y: source.y }
217
+ case "up":
218
+ return { x: source.x, y: bus.sharedBoundary.maxY }
219
+ case "down":
220
+ return { x: source.x, y: bus.sharedBoundary.minY }
221
+ }
222
+ })()
223
+ const directEscapeSegment = {
224
+ start: source,
225
+ end: boundaryPoint,
226
+ width: traceWidth,
227
+ layer: connection.sourceLayer,
228
+ }
229
+ for (const obstacle of srj.obstacles) {
230
+ if (
231
+ obstacle === connection.sourceObstacle ||
232
+ !obstacle.layers.includes(connection.sourceLayer)
233
+ ) {
234
+ continue
235
+ }
236
+ if (
237
+ distanceSegmentToObstacle(directEscapeSegment, obstacle) <
238
+ traceWidth / 2 + clearance - 1e-9
239
+ ) {
240
+ return true
241
+ }
242
+ }
243
+ }
244
+ return false
245
+ }
246
+
247
+ function createPreferredLayerAssignment(params: {
248
+ buses: PreparedBus[]
249
+ escapeLayers: string[]
250
+ srj: SimpleRouteJson
251
+ traceWidth: number
252
+ clearance: number
253
+ }): Readonly<Record<string, string>> {
254
+ const { buses, escapeLayers, srj, traceWidth, clearance } = params
255
+ const assignment: Record<string, string> = {}
256
+ const directionsByComponent = new Map<string, Set<PreparedBus["direction"]>>()
257
+ let nextViaLayerIndex = 0
258
+ for (const bus of buses) {
259
+ const directions = directionsByComponent.get(bus.componentId) ?? new Set()
260
+ directions.add(bus.direction)
261
+ directionsByComponent.set(bus.componentId, directions)
262
+ }
263
+
264
+ for (const bus of buses) {
265
+ const sourceLayer = bus.connections[0]?.sourceLayer
266
+ if (!sourceLayer) {
267
+ throw new Error(`FanoutSolver: bus "${bus.busId}" has no connections`)
268
+ }
269
+ if (bus.termination.type === "plane") {
270
+ assignment[bus.busId] = bus.termination.layer
271
+ continue
272
+ }
273
+ const viaLayers = escapeLayers.filter((layer) => layer !== sourceLayer)
274
+ if (
275
+ escapeLayers.includes(sourceLayer) &&
276
+ busIsOnOutwardComponentEdge(bus) &&
277
+ !sourceLayerEscapeIsBlocked({
278
+ bus,
279
+ srj,
280
+ traceWidth,
281
+ clearance,
282
+ })
283
+ ) {
284
+ assignment[bus.busId] = sourceLayer
285
+ } else if (viaLayers.length > 0) {
286
+ const componentDirections = directionsByComponent.get(bus.componentId)!
287
+ const hasOpposingDirection =
288
+ (componentDirections.has("left") && componentDirections.has("right")) ||
289
+ (componentDirections.has("up") && componentDirections.has("down"))
290
+ if (hasOpposingDirection) {
291
+ const depthInRows = getBusDepthInRows(bus)
292
+ assignment[bus.busId] =
293
+ viaLayers[Math.max(depthInRows - 1, 0) % viaLayers.length]!
294
+ } else {
295
+ assignment[bus.busId] = viaLayers[nextViaLayerIndex % viaLayers.length]!
296
+ nextViaLayerIndex++
297
+ }
298
+ } else {
299
+ assignment[bus.busId] = sourceLayer
300
+ }
301
+ }
302
+
303
+ return assignment
304
+ }
305
+
306
+ function prioritizeLayerAssignment(params: {
307
+ preferredAssignment: Readonly<Record<string, string>>
308
+ generatedAssignments: Array<Readonly<Record<string, string>>>
309
+ maxAssignments: number
310
+ }): Array<Readonly<Record<string, string>>> {
311
+ const { preferredAssignment, generatedAssignments, maxAssignments } = params
312
+ const preferredKey = JSON.stringify(preferredAssignment)
313
+ return [
314
+ preferredAssignment,
315
+ ...generatedAssignments.filter(
316
+ (assignment) => JSON.stringify(assignment) !== preferredKey,
317
+ ),
318
+ ].slice(0, maxAssignments)
319
+ }
320
+
321
+ export class FanoutSolver extends BaseSolver {
322
+ readonly preparedBuses: PreparedBus[]
323
+ readonly attempts: FanoutAttemptSummary[] = []
324
+ readonly layerAssignments: Array<Readonly<Record<string, string>>>
325
+ readonly config: ResolvedFanoutConfig
326
+ private nextAssignmentIndex = 0
327
+ private bestAttempt: AssignmentAttempt | null = null
328
+
329
+ constructor(
330
+ public readonly inputSrj: SimpleRouteJson,
331
+ public readonly options: FanoutSolverOptions = {},
332
+ ) {
333
+ super()
334
+ this.config = resolveConfig(inputSrj, options)
335
+ this.preparedBuses = prepareFanoutBuses(inputSrj, options)
336
+ for (const bus of this.preparedBuses) {
337
+ if (bus.termination.type !== "plane") continue
338
+ const planeLayer = bus.termination.layer
339
+ if (!this.config.layerNames.includes(planeLayer)) {
340
+ throw new Error(
341
+ `FanoutSolver: plane-terminated bus "${bus.busId}" targets unavailable layer "${planeLayer}"`,
342
+ )
343
+ }
344
+ if (
345
+ bus.connections.some(
346
+ (connection) => connection.sourceLayer === planeLayer,
347
+ )
348
+ ) {
349
+ throw new Error(
350
+ `FanoutSolver: plane-terminated bus "${bus.busId}" must target a layer below its source pad`,
351
+ )
352
+ }
353
+ }
354
+ const boundaryBusIds = this.preparedBuses
355
+ .filter((bus) => bus.termination.type === "boundary")
356
+ .map((bus) => bus.busId)
357
+ const fixedPlaneAssignments = Object.fromEntries(
358
+ this.preparedBuses.flatMap((bus) =>
359
+ bus.termination.type === "plane"
360
+ ? [[bus.busId, bus.termination.layer] as const]
361
+ : [],
362
+ ),
363
+ )
364
+ const generatedAssignments = generateLayerAssignments({
365
+ busIds: boundaryBusIds,
366
+ layers: this.config.escapeLayers,
367
+ maxAssignments: this.config.maxLayerCombinations,
368
+ }).map((assignment) => ({
369
+ ...assignment,
370
+ ...fixedPlaneAssignments,
371
+ }))
372
+ this.layerAssignments = prioritizeLayerAssignment({
373
+ preferredAssignment: createPreferredLayerAssignment({
374
+ buses: this.preparedBuses,
375
+ escapeLayers: this.config.escapeLayers,
376
+ srj: inputSrj,
377
+ traceWidth: this.config.traceWidth,
378
+ clearance: this.config.clearance,
379
+ }),
380
+ generatedAssignments,
381
+ maxAssignments: this.config.maxLayerCombinations,
382
+ })
383
+ this.MAX_ITERATIONS = this.layerAssignments.length + 2
384
+ }
385
+
386
+ override getSolverName(): string {
387
+ return "FanoutSolver"
388
+ }
389
+
390
+ private evaluateAssignment(
391
+ assignmentIndex: number,
392
+ busLayerAssignments: Readonly<Record<string, string>>,
393
+ ): AssignmentAttempt {
394
+ const plans: AssignmentAttempt["plans"] = []
395
+ const failedBusIds: string[] = []
396
+ const isSingleLayerFanout = this.config.escapeLayers.length === 1
397
+ if (isSingleLayerFanout && this.config.singleLayerPushAndShove) {
398
+ const singleLayerParams = {
399
+ srj: this.inputSrj,
400
+ buses: this.preparedBuses,
401
+ traceWidth: this.config.traceWidth,
402
+ clearance: this.config.clearance,
403
+ borderDistribution: this.config.borderDistribution,
404
+ }
405
+ const singleLayerPlans =
406
+ routeSingleLayerWithPushAndShove(singleLayerParams) ??
407
+ (this.config.singleLayerAdaptiveExits &&
408
+ this.options.availableCornersAndSides === undefined
409
+ ? routeSingleLayerWithAdaptiveExits(singleLayerParams)
410
+ : null)
411
+ if (singleLayerPlans) {
412
+ plans.push(...singleLayerPlans)
413
+ } else {
414
+ failedBusIds.push(...this.preparedBuses.map((bus) => bus.busId))
415
+ }
416
+ }
417
+ const busesInRoutingOrder = [...this.preparedBuses].sort(
418
+ (a, b) =>
419
+ Number(a.termination.type === "plane") -
420
+ Number(b.termination.type === "plane") ||
421
+ b.componentObstacles.length - a.componentObstacles.length ||
422
+ (isSingleLayerFanout
423
+ ? getBusDistanceToBoundary(b) - getBusDistanceToBoundary(a)
424
+ : b.connections.length - a.connections.length ||
425
+ getBusDistanceToBoundary(a) - getBusDistanceToBoundary(b)),
426
+ )
427
+
428
+ for (const bus of isSingleLayerFanout && this.config.singleLayerPushAndShove
429
+ ? []
430
+ : busesInRoutingOrder) {
431
+ const targetLayer = busLayerAssignments[bus.busId]
432
+ if (!targetLayer) {
433
+ throw new Error(
434
+ `FanoutSolver: assignment ${assignmentIndex} has no layer for bus "${bus.busId}"`,
435
+ )
436
+ }
437
+ const busPlans = routeBus({
438
+ srj: this.inputSrj,
439
+ bus,
440
+ targetLayer,
441
+ acceptedPlans: plans,
442
+ layerNames: this.config.layerNames,
443
+ traceWidth: this.config.traceWidth,
444
+ viaDiameter: this.config.viaDiameter,
445
+ viaHoleDiameter: this.config.viaHoleDiameter,
446
+ clearance: this.config.clearance,
447
+ compactBusTracks: this.config.compactBusTracks,
448
+ })
449
+ if (!busPlans) {
450
+ failedBusIds.push(bus.busId)
451
+ continue
452
+ }
453
+ plans.push(...busPlans)
454
+ }
455
+
456
+ const routedBusCount = this.preparedBuses.length - failedBusIds.length
457
+ const routeLength = plans.reduce((total, plan) => total + plan.length, 0)
458
+ const unroutedConnectionCount =
459
+ this.inputSrj.connections.length - plans.length
460
+ const score =
461
+ unroutedConnectionCount * 1_000_000 +
462
+ failedBusIds.length * 100_000 +
463
+ routeLength +
464
+ plans.filter((plan) => plan.via).length * 0.1 +
465
+ assignmentLoadPenalty(busLayerAssignments) * 0.01
466
+ const summary: FanoutAttemptSummary = {
467
+ assignmentIndex,
468
+ busLayerAssignments,
469
+ routedBusCount,
470
+ routedConnectionCount: plans.length,
471
+ failedBusIds,
472
+ score,
473
+ }
474
+
475
+ return {
476
+ summary,
477
+ plans,
478
+ outputSrj: buildOutputSimpleRouteJson({
479
+ inputSrj: this.inputSrj,
480
+ plans,
481
+ layerNames: this.config.layerNames,
482
+ }),
483
+ }
484
+ }
485
+
486
+ override _step(): void {
487
+ const assignment = this.layerAssignments[this.nextAssignmentIndex]
488
+ if (!assignment) {
489
+ if (
490
+ this.bestAttempt &&
491
+ this.bestAttempt.summary.routedConnectionCount ===
492
+ this.inputSrj.connections.length
493
+ ) {
494
+ this.solved = true
495
+ } else {
496
+ this.failed = true
497
+ this.error = this.bestAttempt
498
+ ? `FanoutSolver: best layer assignment routed ${this.bestAttempt.summary.routedConnectionCount}/${this.inputSrj.connections.length} connections`
499
+ : "FanoutSolver: no layer assignment could be evaluated"
500
+ }
501
+ return
502
+ }
503
+
504
+ const attempt = this.evaluateAssignment(
505
+ this.nextAssignmentIndex,
506
+ assignment,
507
+ )
508
+ this.nextAssignmentIndex++
509
+ this.attempts.push(attempt.summary)
510
+ if (
511
+ !this.bestAttempt ||
512
+ attempt.summary.score < this.bestAttempt.summary.score
513
+ ) {
514
+ this.bestAttempt = attempt
515
+ }
516
+ this.stats = {
517
+ assignment: attempt.summary.assignmentIndex + 1,
518
+ assignmentCount: this.layerAssignments.length,
519
+ routedBuses: `${attempt.summary.routedBusCount}/${this.preparedBuses.length}`,
520
+ routedConnections: `${attempt.summary.routedConnectionCount}/${this.inputSrj.connections.length}`,
521
+ failedBuses: attempt.summary.failedBusIds.join(", ") || "none",
522
+ bestScore: this.bestAttempt.summary.score,
523
+ }
524
+ if (
525
+ attempt.summary.routedConnectionCount === this.inputSrj.connections.length
526
+ ) {
527
+ this.solved = true
528
+ }
529
+ }
530
+
531
+ computeProgress(): number {
532
+ if (this.solved || this.failed) return 1
533
+ return this.nextAssignmentIndex / this.layerAssignments.length
534
+ }
535
+
536
+ override getConstructorParams(): [SimpleRouteJson, FanoutSolverOptions] {
537
+ return [this.inputSrj, this.options]
538
+ }
539
+
540
+ override getOutput(): FanoutSolverOutput {
541
+ if (!this.solved || !this.bestAttempt) {
542
+ throw new Error(
543
+ "FanoutSolver: getOutput() called before a complete fanout was solved",
544
+ )
545
+ }
546
+ return {
547
+ simpleRouteJson: this.bestAttempt.outputSrj,
548
+ fanoutTraces: this.bestAttempt.plans.map((plan) => plan.trace),
549
+ planeTerminations: this.bestAttempt.plans.flatMap((plan) =>
550
+ plan.termination.type === "plane" && plan.via
551
+ ? [
552
+ {
553
+ busId: plan.busId,
554
+ connectionName: plan.connectionName,
555
+ layer: plan.termination.layer,
556
+ via: plan.via,
557
+ },
558
+ ]
559
+ : [],
560
+ ),
561
+ busLayerAssignments: this.bestAttempt.summary.busLayerAssignments,
562
+ busDirections: Object.fromEntries(
563
+ this.preparedBuses.map((bus) => [bus.busId, bus.direction]),
564
+ ),
565
+ attempts: [...this.attempts],
566
+ }
567
+ }
568
+
569
+ getOutputSimpleRouteJson(): SimpleRouteJson {
570
+ return this.getOutput().simpleRouteJson
571
+ }
572
+
573
+ override visualize(): GraphicsObject {
574
+ const visualizedSrj = this.bestAttempt?.outputSrj ?? this.inputSrj
575
+ const graphics = convertSrjToGraphicsObject(visualizedSrj)
576
+ const circularPadKeys = new Set(
577
+ visualizedSrj.obstacles
578
+ .filter(
579
+ (obstacle) =>
580
+ (obstacle as typeof obstacle & { shape?: string }).shape ===
581
+ "circle",
582
+ )
583
+ .map(
584
+ (obstacle) =>
585
+ `${obstacle.center.x}:${obstacle.center.y}:${obstacle.width}:${obstacle.height}`,
586
+ ),
587
+ )
588
+ const circularPadGraphics: NonNullable<GraphicsObject["circles"]> = []
589
+ const rects = graphics.rects?.filter((rect) => {
590
+ const key = `${rect.center.x}:${rect.center.y}:${rect.width}:${rect.height}`
591
+ if (!circularPadKeys.has(key)) return true
592
+ circularPadGraphics.push({
593
+ center: rect.center,
594
+ radius: Math.min(rect.width, rect.height) / 2,
595
+ fill: rect.fill,
596
+ stroke: rect.stroke,
597
+ layer: rect.layer,
598
+ label: rect.label,
599
+ })
600
+ return false
601
+ })
602
+ return {
603
+ ...graphics,
604
+ rects,
605
+ circles: [...(graphics.circles ?? []), ...circularPadGraphics],
606
+ lines: graphics.lines?.map((line) => {
607
+ const layerMatch = /^z(\d+)$/.exec(line.layer ?? "")
608
+ if (!layerMatch) return line
609
+ const { strokeDash: _strokeDash, ...solidLine } = line
610
+ return {
611
+ ...solidLine,
612
+ strokeColor: getCopperLayerColor(Number(layerMatch[1])),
613
+ }
614
+ }),
615
+ }
616
+ }
617
+ }