@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,1016 @@
1
+ import type {
2
+ ConnectionPoint,
3
+ Obstacle,
4
+ SimpleRouteConnection,
5
+ SimpleRouteJson,
6
+ } from "@tscircuit/capacity-autorouter"
7
+ import { distance, pointIsInsideObstacle } from "./geometry"
8
+ import type {
9
+ Bounds,
10
+ FanoutAvailableCornerAndSideInput,
11
+ FanoutBorderTarget,
12
+ FanoutBusSpec,
13
+ FanoutBusTermination,
14
+ FanoutDirection,
15
+ FanoutSolverOptions,
16
+ PreparedBus,
17
+ PreparedConnection,
18
+ } from "./types"
19
+
20
+ interface AvailableBoundaryRegion {
21
+ direction: FanoutDirection
22
+ preferredExit: FanoutBorderTarget
23
+ }
24
+
25
+ const FANOUT_BORDER_TARGETS = new Set<FanoutBorderTarget>([
26
+ "left",
27
+ "right",
28
+ "top",
29
+ "bottom",
30
+ "top-left",
31
+ "top-right",
32
+ "bottom-left",
33
+ "bottom-right",
34
+ ])
35
+
36
+ const AVAILABLE_BOUNDARY_REGIONS: Readonly<
37
+ Record<FanoutAvailableCornerAndSideInput, AvailableBoundaryRegion>
38
+ > = {
39
+ top_left: {
40
+ direction: "up",
41
+ preferredExit: "top-left",
42
+ },
43
+ top_middle: {
44
+ direction: "up",
45
+ preferredExit: "top",
46
+ },
47
+ top_right: {
48
+ direction: "up",
49
+ preferredExit: "top-right",
50
+ },
51
+ right_top: {
52
+ direction: "right",
53
+ preferredExit: "top-right",
54
+ },
55
+ right_middle: {
56
+ direction: "right",
57
+ preferredExit: "right",
58
+ },
59
+ right_bottom: {
60
+ direction: "right",
61
+ preferredExit: "bottom-right",
62
+ },
63
+ bottom_right: {
64
+ direction: "down",
65
+ preferredExit: "bottom-right",
66
+ },
67
+ bottom_middle: {
68
+ direction: "down",
69
+ preferredExit: "bottom",
70
+ },
71
+ bottom_left: {
72
+ direction: "down",
73
+ preferredExit: "bottom-left",
74
+ },
75
+ left_bottom: {
76
+ direction: "left",
77
+ preferredExit: "bottom-left",
78
+ },
79
+ left_middle: {
80
+ direction: "left",
81
+ preferredExit: "left",
82
+ },
83
+ left_top: {
84
+ direction: "left",
85
+ preferredExit: "top-left",
86
+ },
87
+ top: {
88
+ direction: "up",
89
+ preferredExit: "top",
90
+ },
91
+ right: {
92
+ direction: "right",
93
+ preferredExit: "right",
94
+ },
95
+ bottom: {
96
+ direction: "down",
97
+ preferredExit: "bottom",
98
+ },
99
+ left: {
100
+ direction: "left",
101
+ preferredExit: "left",
102
+ },
103
+ }
104
+
105
+ interface ComponentGrid {
106
+ componentId: string
107
+ obstacles: Obstacle[]
108
+ xCoordinates: number[]
109
+ yCoordinates: number[]
110
+ pitchX: number
111
+ pitchY: number
112
+ bounds: Bounds
113
+ }
114
+
115
+ function uniqueSorted(values: number[]): number[] {
116
+ const sortedValues = [...values].sort((a, b) => a - b)
117
+ const result: number[] = []
118
+ for (const value of sortedValues) {
119
+ if (
120
+ result.length === 0 ||
121
+ Math.abs(result[result.length - 1]! - value) > 1e-6
122
+ ) {
123
+ result.push(value)
124
+ }
125
+ }
126
+ return result
127
+ }
128
+
129
+ function getPitch(coordinates: number[]): number {
130
+ let pitch = Number.POSITIVE_INFINITY
131
+ for (let index = 1; index < coordinates.length; index++) {
132
+ const difference = coordinates[index]! - coordinates[index - 1]!
133
+ if (difference > 1e-6) pitch = Math.min(pitch, difference)
134
+ }
135
+ return pitch
136
+ }
137
+
138
+ function getAlignedPitch(obstacles: Obstacle[], axis: "x" | "y"): number {
139
+ const perpendicularAxis = axis === "x" ? "y" : "x"
140
+ let pitch = Number.POSITIVE_INFINITY
141
+ for (let firstIndex = 0; firstIndex < obstacles.length; firstIndex++) {
142
+ const first = obstacles[firstIndex]!
143
+ for (
144
+ let secondIndex = firstIndex + 1;
145
+ secondIndex < obstacles.length;
146
+ secondIndex++
147
+ ) {
148
+ const second = obstacles[secondIndex]!
149
+ if (
150
+ Math.abs(
151
+ first.center[perpendicularAxis] - second.center[perpendicularAxis],
152
+ ) > 1e-6
153
+ ) {
154
+ continue
155
+ }
156
+ const separation = Math.abs(first.center[axis] - second.center[axis])
157
+ if (separation > 1e-6) pitch = Math.min(pitch, separation)
158
+ }
159
+ }
160
+ return pitch
161
+ }
162
+
163
+ function getComponentBounds(obstacles: Obstacle[]): Bounds {
164
+ return {
165
+ minX: Math.min(
166
+ ...obstacles.map((obstacle) => obstacle.center.x - obstacle.width / 2),
167
+ ),
168
+ maxX: Math.max(
169
+ ...obstacles.map((obstacle) => obstacle.center.x + obstacle.width / 2),
170
+ ),
171
+ minY: Math.min(
172
+ ...obstacles.map((obstacle) => obstacle.center.y - obstacle.height / 2),
173
+ ),
174
+ maxY: Math.max(
175
+ ...obstacles.map((obstacle) => obstacle.center.y + obstacle.height / 2),
176
+ ),
177
+ }
178
+ }
179
+
180
+ function resolveComponentBounds(
181
+ grid: ComponentGrid,
182
+ options: FanoutSolverOptions,
183
+ ): Bounds {
184
+ const requestedBounds = options.componentBounds?.[grid.componentId]
185
+ if (!requestedBounds) {
186
+ const inferredMarginX = grid.pitchX * 2.25
187
+ const inferredMarginY = grid.pitchY * 2.25
188
+ return {
189
+ minX: grid.bounds.minX - inferredMarginX,
190
+ maxX: grid.bounds.maxX + inferredMarginX,
191
+ minY: grid.bounds.minY - inferredMarginY,
192
+ maxY: grid.bounds.maxY + inferredMarginY,
193
+ }
194
+ }
195
+
196
+ const values = [
197
+ requestedBounds.minX,
198
+ requestedBounds.maxX,
199
+ requestedBounds.minY,
200
+ requestedBounds.maxY,
201
+ ]
202
+ if (
203
+ values.some((value) => !Number.isFinite(value)) ||
204
+ requestedBounds.minX >= requestedBounds.maxX ||
205
+ requestedBounds.minY >= requestedBounds.maxY
206
+ ) {
207
+ throw new Error(
208
+ `FanoutSolver: componentBounds for "${grid.componentId}" must contain finite, increasing bounds`,
209
+ )
210
+ }
211
+ if (
212
+ requestedBounds.minX > grid.bounds.minX + 1e-6 ||
213
+ requestedBounds.maxX < grid.bounds.maxX - 1e-6 ||
214
+ requestedBounds.minY > grid.bounds.minY + 1e-6 ||
215
+ requestedBounds.maxY < grid.bounds.maxY - 1e-6
216
+ ) {
217
+ throw new Error(
218
+ `FanoutSolver: componentBounds for "${grid.componentId}" must contain every component pad`,
219
+ )
220
+ }
221
+
222
+ return { ...requestedBounds }
223
+ }
224
+
225
+ function validateSharedBoundary(
226
+ boundary: Bounds,
227
+ componentGrids: ComponentGrid[],
228
+ ): Bounds {
229
+ const values = [boundary.minX, boundary.maxX, boundary.minY, boundary.maxY]
230
+ if (
231
+ values.some((value) => !Number.isFinite(value)) ||
232
+ boundary.minX >= boundary.maxX ||
233
+ boundary.minY >= boundary.maxY
234
+ ) {
235
+ throw new Error(
236
+ "FanoutSolver: sharedBoundary must contain finite, increasing bounds",
237
+ )
238
+ }
239
+ for (const grid of componentGrids) {
240
+ if (
241
+ boundary.minX > grid.bounds.minX + 1e-6 ||
242
+ boundary.maxX < grid.bounds.maxX - 1e-6 ||
243
+ boundary.minY > grid.bounds.minY + 1e-6 ||
244
+ boundary.maxY < grid.bounds.maxY - 1e-6
245
+ ) {
246
+ throw new Error(
247
+ `FanoutSolver: sharedBoundary must contain every pad of component "${grid.componentId}"`,
248
+ )
249
+ }
250
+ }
251
+ return { ...boundary }
252
+ }
253
+
254
+ function resolveSharedBoundary(
255
+ componentGrids: ComponentGrid[],
256
+ options: FanoutSolverOptions,
257
+ ): Bounds {
258
+ if (options.sharedBoundary) {
259
+ return validateSharedBoundary(options.sharedBoundary, componentGrids)
260
+ }
261
+
262
+ const componentBounds = componentGrids.map((grid) =>
263
+ resolveComponentBounds(grid, options),
264
+ )
265
+ const maximumPitch = Math.max(
266
+ ...componentGrids.flatMap((grid) => [grid.pitchX, grid.pitchY]),
267
+ )
268
+ const inferredMargin = maximumPitch * 2.25
269
+ return validateSharedBoundary(
270
+ {
271
+ minX:
272
+ Math.min(...componentBounds.map((bounds) => bounds.minX)) -
273
+ inferredMargin,
274
+ maxX:
275
+ Math.max(...componentBounds.map((bounds) => bounds.maxX)) +
276
+ inferredMargin,
277
+ minY:
278
+ Math.min(...componentBounds.map((bounds) => bounds.minY)) -
279
+ inferredMargin,
280
+ maxY:
281
+ Math.max(...componentBounds.map((bounds) => bounds.maxY)) +
282
+ inferredMargin,
283
+ },
284
+ componentGrids,
285
+ )
286
+ }
287
+
288
+ function findComponentGrids(obstacles: Obstacle[]): ComponentGrid[] {
289
+ const obstaclesByComponent = new Map<string, Obstacle[]>()
290
+ for (const obstacle of obstacles) {
291
+ if (!obstacle.componentId || obstacle.isCopperPour) continue
292
+ const componentObstacles =
293
+ obstaclesByComponent.get(obstacle.componentId) ?? []
294
+ componentObstacles.push(obstacle)
295
+ obstaclesByComponent.set(obstacle.componentId, componentObstacles)
296
+ }
297
+
298
+ const grids: ComponentGrid[] = []
299
+ for (const [componentId, componentObstacles] of obstaclesByComponent) {
300
+ const xCoordinates = uniqueSorted(
301
+ componentObstacles.map((obstacle) => obstacle.center.x),
302
+ )
303
+ const yCoordinates = uniqueSorted(
304
+ componentObstacles.map((obstacle) => obstacle.center.y),
305
+ )
306
+ const alignedPitchX = getAlignedPitch(componentObstacles, "x")
307
+ const alignedPitchY = getAlignedPitch(componentObstacles, "y")
308
+ const coordinatePitchX = getPitch(xCoordinates)
309
+ const coordinatePitchY = getPitch(yCoordinates)
310
+ const fallbackPitch = Math.min(
311
+ ...[
312
+ alignedPitchX,
313
+ alignedPitchY,
314
+ coordinatePitchX,
315
+ coordinatePitchY,
316
+ ].filter(Number.isFinite),
317
+ )
318
+ const padSizeFallback = Math.max(
319
+ ...componentObstacles.flatMap((obstacle) => [
320
+ obstacle.width,
321
+ obstacle.height,
322
+ ]),
323
+ )
324
+ const resolvedFallback = Number.isFinite(fallbackPitch)
325
+ ? fallbackPitch
326
+ : padSizeFallback
327
+ const pitchX = Number.isFinite(alignedPitchX)
328
+ ? alignedPitchX
329
+ : Number.isFinite(coordinatePitchX)
330
+ ? coordinatePitchX
331
+ : resolvedFallback
332
+ const pitchY = Number.isFinite(alignedPitchY)
333
+ ? alignedPitchY
334
+ : Number.isFinite(coordinatePitchY)
335
+ ? coordinatePitchY
336
+ : resolvedFallback
337
+ grids.push({
338
+ componentId,
339
+ obstacles: componentObstacles,
340
+ xCoordinates,
341
+ yCoordinates,
342
+ pitchX,
343
+ pitchY,
344
+ bounds: getComponentBounds(componentObstacles),
345
+ })
346
+ }
347
+ return grids
348
+ }
349
+
350
+ function getPointLayers(point: ConnectionPoint): string[] {
351
+ return "layer" in point ? [point.layer] : point.layers
352
+ }
353
+
354
+ function findPointObstacleMatches(params: {
355
+ point: ConnectionPoint
356
+ connection: SimpleRouteConnection
357
+ componentGrids: ComponentGrid[]
358
+ }): Array<{ grid: ComponentGrid; obstacle: Obstacle }> {
359
+ const { point, connection, componentGrids } = params
360
+ const pointLayers = getPointLayers(point)
361
+ const matches: Array<{ grid: ComponentGrid; obstacle: Obstacle }> = []
362
+
363
+ for (const grid of componentGrids) {
364
+ const candidateObstacles = grid.obstacles
365
+ .filter((obstacle) =>
366
+ obstacle.layers.some((layer) => pointLayers.includes(layer)),
367
+ )
368
+ .filter((obstacle) => pointIsInsideObstacle(point, obstacle, 1e-5))
369
+ .sort((a, b) => {
370
+ const aDirect =
371
+ a.connectedTo.includes(connection.name) ||
372
+ a.connectedTo.includes(point.pointId ?? "") ||
373
+ a.connectedTo.includes(point.pcb_port_id ?? "")
374
+ const bDirect =
375
+ b.connectedTo.includes(connection.name) ||
376
+ b.connectedTo.includes(point.pointId ?? "") ||
377
+ b.connectedTo.includes(point.pcb_port_id ?? "")
378
+ if (aDirect !== bDirect) return aDirect ? -1 : 1
379
+ return a.width * a.height - b.width * b.height
380
+ })
381
+ if (candidateObstacles[0]) {
382
+ matches.push({ grid, obstacle: candidateObstacles[0] })
383
+ }
384
+ }
385
+
386
+ return matches
387
+ }
388
+
389
+ function inferBusId(connection: SimpleRouteConnection): string | null {
390
+ for (const point of connection.pointsToConnect) {
391
+ if ("layers" in point && point.busId) return point.busId
392
+ }
393
+ const nameMatch = /^BUS[_:-]([^_:-]+)(?:[_:-]\d+)?$/i.exec(connection.name)
394
+ return nameMatch?.[1] ?? null
395
+ }
396
+
397
+ function resolvePreferredExit(
398
+ busId: string,
399
+ value: FanoutBorderTarget | undefined,
400
+ ): FanoutBorderTarget | undefined {
401
+ if (value === undefined) return undefined
402
+ if (!FANOUT_BORDER_TARGETS.has(value)) {
403
+ throw new Error(
404
+ `FanoutSolver: bus "${busId}" has invalid preferredExit "${value}"`,
405
+ )
406
+ }
407
+ return value
408
+ }
409
+
410
+ function resolveAvailableBoundaryRegions(
411
+ value: readonly FanoutAvailableCornerAndSideInput[] | undefined,
412
+ ): AvailableBoundaryRegion[] | undefined {
413
+ if (value === undefined) return undefined
414
+ if (value.length === 0) {
415
+ throw new Error(
416
+ "FanoutSolver: availableCornersAndSides must contain at least one boundary region",
417
+ )
418
+ }
419
+
420
+ const regions: AvailableBoundaryRegion[] = []
421
+ const seen = new Set<string>()
422
+ for (const input of value) {
423
+ const region = AVAILABLE_BOUNDARY_REGIONS[input]
424
+ if (!region) {
425
+ throw new Error(
426
+ `FanoutSolver: invalid availableCornersAndSides value "${input}"`,
427
+ )
428
+ }
429
+ const key = `${region.direction}:${region.preferredExit}`
430
+ if (seen.has(key)) continue
431
+ seen.add(key)
432
+ regions.push(region)
433
+ }
434
+ return regions
435
+ }
436
+
437
+ function resolveTermination(
438
+ busId: string,
439
+ value: FanoutBusTermination | undefined,
440
+ ): FanoutBusTermination {
441
+ if (value === undefined || value.type === "boundary") {
442
+ return { type: "boundary" }
443
+ }
444
+ if (
445
+ value.type !== "plane" ||
446
+ typeof value.layer !== "string" ||
447
+ value.layer.length === 0
448
+ ) {
449
+ throw new Error(
450
+ `FanoutSolver: bus "${busId}" has an invalid termination target`,
451
+ )
452
+ }
453
+ return { type: "plane", layer: value.layer }
454
+ }
455
+
456
+ function resolveBusSpecs(
457
+ srj: SimpleRouteJson,
458
+ options: FanoutSolverOptions,
459
+ ): FanoutBusSpec[] {
460
+ const requestedBuses = options.buses ?? srj.buses
461
+ const specsById = new Map<string, FanoutBusSpec>()
462
+ const claimedConnectionNames = new Set<string>()
463
+ const knownConnectionNames = new Set(
464
+ srj.connections.map((connection) => connection.name),
465
+ )
466
+
467
+ for (const requestedBus of requestedBuses ?? []) {
468
+ if (specsById.has(requestedBus.busId)) {
469
+ throw new Error(`FanoutSolver: duplicate bus id "${requestedBus.busId}"`)
470
+ }
471
+ for (const connectionName of requestedBus.connectionNames) {
472
+ if (!knownConnectionNames.has(connectionName)) {
473
+ throw new Error(
474
+ `FanoutSolver: bus "${requestedBus.busId}" references unknown connection "${connectionName}"`,
475
+ )
476
+ }
477
+ if (claimedConnectionNames.has(connectionName)) {
478
+ throw new Error(
479
+ `FanoutSolver: connection "${connectionName}" belongs to more than one bus`,
480
+ )
481
+ }
482
+ claimedConnectionNames.add(connectionName)
483
+ }
484
+ const termination = resolveTermination(
485
+ requestedBus.busId,
486
+ (requestedBus as FanoutBusSpec).termination,
487
+ )
488
+ const preferredExit = resolvePreferredExit(
489
+ requestedBus.busId,
490
+ options.busExitPreferences?.[requestedBus.busId] ??
491
+ (requestedBus as FanoutBusSpec).preferredExit ??
492
+ options.defaultPreferredExit,
493
+ )
494
+ if (termination.type === "plane" && preferredExit !== undefined) {
495
+ throw new Error(
496
+ `FanoutSolver: plane-terminated bus "${requestedBus.busId}" cannot also specify preferredExit`,
497
+ )
498
+ }
499
+ specsById.set(requestedBus.busId, {
500
+ ...requestedBus,
501
+ sourceComponentId:
502
+ (requestedBus as FanoutBusSpec).sourceComponentId ??
503
+ options.sourceComponentId,
504
+ direction:
505
+ options.busDirections?.[requestedBus.busId] ??
506
+ (requestedBus as FanoutBusSpec).direction ??
507
+ options.defaultDirection,
508
+ preferredExit,
509
+ termination,
510
+ })
511
+ }
512
+
513
+ for (const connection of srj.connections) {
514
+ if (claimedConnectionNames.has(connection.name)) continue
515
+ const inferredBusId = inferBusId(connection)
516
+ if (inferredBusId) {
517
+ const existing = specsById.get(inferredBusId)
518
+ specsById.set(inferredBusId, {
519
+ busId: inferredBusId,
520
+ connectionNames: [
521
+ ...(existing?.connectionNames ?? []),
522
+ connection.name,
523
+ ],
524
+ direction:
525
+ options.busDirections?.[inferredBusId] ??
526
+ existing?.direction ??
527
+ options.defaultDirection,
528
+ sourceComponentId:
529
+ existing?.sourceComponentId ?? options.sourceComponentId,
530
+ preferredExit: resolvePreferredExit(
531
+ inferredBusId,
532
+ options.busExitPreferences?.[inferredBusId] ??
533
+ existing?.preferredExit ??
534
+ options.defaultPreferredExit,
535
+ ),
536
+ termination: existing?.termination ?? { type: "boundary" },
537
+ })
538
+ } else {
539
+ const singletonBusId = `connection:${connection.name}`
540
+ specsById.set(singletonBusId, {
541
+ busId: singletonBusId,
542
+ connectionNames: [connection.name],
543
+ sourceComponentId: options.sourceComponentId,
544
+ direction:
545
+ options.busDirections?.[singletonBusId] ?? options.defaultDirection,
546
+ preferredExit: resolvePreferredExit(
547
+ singletonBusId,
548
+ options.busExitPreferences?.[singletonBusId] ??
549
+ options.defaultPreferredExit,
550
+ ),
551
+ termination: { type: "boundary" },
552
+ })
553
+ }
554
+ }
555
+
556
+ return [...specsById.values()]
557
+ }
558
+
559
+ function chooseSourceGrid(params: {
560
+ busSpec: FanoutBusSpec
561
+ connections: SimpleRouteConnection[]
562
+ componentGrids: ComponentGrid[]
563
+ }): ComponentGrid {
564
+ const { busSpec, connections, componentGrids } = params
565
+ const matchCountByComponent = new Map<string, number>()
566
+
567
+ for (const connection of connections) {
568
+ const matchedComponents = new Set<string>()
569
+ for (const point of connection.pointsToConnect) {
570
+ for (const match of findPointObstacleMatches({
571
+ point,
572
+ connection,
573
+ componentGrids,
574
+ })) {
575
+ matchedComponents.add(match.grid.componentId)
576
+ }
577
+ }
578
+ for (const componentId of matchedComponents) {
579
+ matchCountByComponent.set(
580
+ componentId,
581
+ (matchCountByComponent.get(componentId) ?? 0) + 1,
582
+ )
583
+ }
584
+ }
585
+
586
+ const selectedGrid = [...componentGrids].sort((a, b) => {
587
+ const countDifference =
588
+ (matchCountByComponent.get(b.componentId) ?? 0) -
589
+ (matchCountByComponent.get(a.componentId) ?? 0)
590
+ if (countDifference !== 0) return countDifference
591
+ return b.obstacles.length - a.obstacles.length
592
+ })[0]
593
+ const requestedGrid = busSpec.sourceComponentId
594
+ ? componentGrids.find(
595
+ (grid) => grid.componentId === busSpec.sourceComponentId,
596
+ )
597
+ : undefined
598
+ if (busSpec.sourceComponentId && !requestedGrid) {
599
+ throw new Error(
600
+ `FanoutSolver: source component "${busSpec.sourceComponentId}" for bus "${busSpec.busId}" was not found`,
601
+ )
602
+ }
603
+ const sourceGrid = requestedGrid ?? selectedGrid
604
+ const sourceMatchCount = sourceGrid
605
+ ? (matchCountByComponent.get(sourceGrid.componentId) ?? 0)
606
+ : 0
607
+ if (!sourceGrid || sourceMatchCount !== connections.length) {
608
+ throw new Error(
609
+ busSpec.sourceComponentId
610
+ ? `FanoutSolver: source component "${busSpec.sourceComponentId}" is not an endpoint on every connection in bus "${busSpec.busId}"`
611
+ : `FanoutSolver: bus "${busSpec.busId}" does not have one component endpoint on every connection`,
612
+ )
613
+ }
614
+ return sourceGrid
615
+ }
616
+
617
+ function chooseTargetPoint(
618
+ sourcePoint: ConnectionPoint,
619
+ connection: SimpleRouteConnection,
620
+ sourcePointIndex: number,
621
+ termination: FanoutBusTermination,
622
+ ): ConnectionPoint {
623
+ const targetCandidates = connection.pointsToConnect.filter(
624
+ (_, pointIndex) => pointIndex !== sourcePointIndex,
625
+ )
626
+ const targetPoint = targetCandidates.sort(
627
+ (a, b) => distance(sourcePoint, b) - distance(sourcePoint, a),
628
+ )[0]
629
+ if (!targetPoint && termination.type === "plane") {
630
+ return sourcePoint
631
+ }
632
+ if (!targetPoint) {
633
+ throw new Error(
634
+ `FanoutSolver: connection "${connection.name}" has no target beyond its BGA pad`,
635
+ )
636
+ }
637
+ return targetPoint
638
+ }
639
+
640
+ function prepareConnection(params: {
641
+ connection: SimpleRouteConnection
642
+ connectionIndex: number
643
+ sourceGrid: ComponentGrid
644
+ componentGrids: ComponentGrid[]
645
+ termination: FanoutBusTermination
646
+ }): PreparedConnection {
647
+ const {
648
+ connection,
649
+ connectionIndex,
650
+ sourceGrid,
651
+ componentGrids,
652
+ termination,
653
+ } = params
654
+ for (
655
+ let sourcePointIndex = 0;
656
+ sourcePointIndex < connection.pointsToConnect.length;
657
+ sourcePointIndex++
658
+ ) {
659
+ const sourcePoint = connection.pointsToConnect[sourcePointIndex]!
660
+ const sourceMatch = findPointObstacleMatches({
661
+ point: sourcePoint,
662
+ connection,
663
+ componentGrids,
664
+ }).find((match) => match.grid.componentId === sourceGrid.componentId)
665
+ if (!sourceMatch) continue
666
+ const sourceLayer = getPointLayers(sourcePoint).find((layer) =>
667
+ sourceMatch.obstacle.layers.includes(layer),
668
+ )
669
+ if (!sourceLayer) {
670
+ throw new Error(
671
+ `FanoutSolver: connection "${connection.name}" has no source layer shared with its BGA pad`,
672
+ )
673
+ }
674
+ return {
675
+ connection,
676
+ connectionIndex,
677
+ sourcePoint,
678
+ sourcePointIndex,
679
+ sourceLayer,
680
+ sourceObstacle: sourceMatch.obstacle,
681
+ targetPoint: chooseTargetPoint(
682
+ sourcePoint,
683
+ connection,
684
+ sourcePointIndex,
685
+ termination,
686
+ ),
687
+ }
688
+ }
689
+ throw new Error(
690
+ `FanoutSolver: connection "${connection.name}" does not touch component "${sourceGrid.componentId}"`,
691
+ )
692
+ }
693
+
694
+ function inferDirection(
695
+ busId: string,
696
+ connections: PreparedConnection[],
697
+ ): FanoutDirection {
698
+ let dx = 0
699
+ let dy = 0
700
+ for (const preparedConnection of connections) {
701
+ dx += preparedConnection.targetPoint.x - preparedConnection.sourcePoint.x
702
+ dy += preparedConnection.targetPoint.y - preparedConnection.sourcePoint.y
703
+ }
704
+ if (Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9) {
705
+ throw new Error(
706
+ `FanoutSolver: cannot infer an escape direction for bus "${busId}"`,
707
+ )
708
+ }
709
+ if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? "right" : "left"
710
+ return dy >= 0 ? "up" : "down"
711
+ }
712
+
713
+ function getDirectionsForBorderTarget(
714
+ target: FanoutBorderTarget,
715
+ ): FanoutDirection[] {
716
+ switch (target) {
717
+ case "left":
718
+ return ["left"]
719
+ case "right":
720
+ return ["right"]
721
+ case "top":
722
+ return ["up"]
723
+ case "bottom":
724
+ return ["down"]
725
+ case "top-left":
726
+ return ["up", "left"]
727
+ case "top-right":
728
+ return ["up", "right"]
729
+ case "bottom-left":
730
+ return ["down", "left"]
731
+ case "bottom-right":
732
+ return ["down", "right"]
733
+ }
734
+ }
735
+
736
+ function getAverageSourcePoint(connections: PreparedConnection[]): {
737
+ x: number
738
+ y: number
739
+ } {
740
+ return {
741
+ x:
742
+ connections.reduce(
743
+ (sum, connection) => sum + connection.sourcePoint.x,
744
+ 0,
745
+ ) / connections.length,
746
+ y:
747
+ connections.reduce(
748
+ (sum, connection) => sum + connection.sourcePoint.y,
749
+ 0,
750
+ ) / connections.length,
751
+ }
752
+ }
753
+
754
+ function getDistanceToBoundary(
755
+ source: { x: number; y: number },
756
+ direction: FanoutDirection,
757
+ boundary: Bounds,
758
+ ): number {
759
+ switch (direction) {
760
+ case "left":
761
+ return source.x - boundary.minX
762
+ case "right":
763
+ return boundary.maxX - source.x
764
+ case "up":
765
+ return boundary.maxY - source.y
766
+ case "down":
767
+ return source.y - boundary.minY
768
+ }
769
+ }
770
+
771
+ function getRegionAnchor(
772
+ region: AvailableBoundaryRegion,
773
+ boundary: Bounds,
774
+ ): number {
775
+ if (region.direction === "up" || region.direction === "down") {
776
+ if (region.preferredExit.endsWith("left")) return boundary.minX
777
+ if (region.preferredExit.endsWith("right")) return boundary.maxX
778
+ return (boundary.minX + boundary.maxX) / 2
779
+ }
780
+ if (region.preferredExit.startsWith("top")) return boundary.maxY
781
+ if (region.preferredExit.startsWith("bottom")) return boundary.minY
782
+ return (boundary.minY + boundary.maxY) / 2
783
+ }
784
+
785
+ function getRegionSourceCoordinate(
786
+ source: { x: number; y: number },
787
+ direction: FanoutDirection,
788
+ ): number {
789
+ return direction === "up" || direction === "down" ? source.x : source.y
790
+ }
791
+
792
+ function tryInferDirection(
793
+ busId: string,
794
+ connections: PreparedConnection[],
795
+ ): FanoutDirection | undefined {
796
+ try {
797
+ return inferDirection(busId, connections)
798
+ } catch {
799
+ return undefined
800
+ }
801
+ }
802
+
803
+ function resolveAvailableBusExit(params: {
804
+ busId: string
805
+ explicitDirection?: FanoutDirection
806
+ preferredExit?: FanoutBorderTarget
807
+ connections: PreparedConnection[]
808
+ sharedBoundary: Bounds
809
+ availableRegions: AvailableBoundaryRegion[]
810
+ }): { direction: FanoutDirection; preferredExit: FanoutBorderTarget } {
811
+ const {
812
+ busId,
813
+ explicitDirection,
814
+ preferredExit,
815
+ connections,
816
+ sharedBoundary,
817
+ availableRegions,
818
+ } = params
819
+ const compatibleRegions = availableRegions.filter(
820
+ (region) =>
821
+ (explicitDirection === undefined ||
822
+ region.direction === explicitDirection) &&
823
+ (preferredExit === undefined || region.preferredExit === preferredExit),
824
+ )
825
+ if (compatibleRegions.length === 0) {
826
+ throw new Error(
827
+ `FanoutSolver: bus "${busId}" cannot use its requested exit with availableCornersAndSides`,
828
+ )
829
+ }
830
+
831
+ const inferredDirection = explicitDirection
832
+ ? undefined
833
+ : tryInferDirection(busId, connections)
834
+ const preferredDirectionRegions = inferredDirection
835
+ ? compatibleRegions.filter(
836
+ (region) => region.direction === inferredDirection,
837
+ )
838
+ : []
839
+ const candidates =
840
+ preferredDirectionRegions.length > 0
841
+ ? preferredDirectionRegions
842
+ : compatibleRegions
843
+ const averageSource = getAverageSourcePoint(connections)
844
+ return [...candidates].toSorted(
845
+ (first, second) =>
846
+ getDistanceToBoundary(averageSource, first.direction, sharedBoundary) -
847
+ getDistanceToBoundary(
848
+ averageSource,
849
+ second.direction,
850
+ sharedBoundary,
851
+ ) ||
852
+ Math.abs(
853
+ getRegionSourceCoordinate(averageSource, first.direction) -
854
+ getRegionAnchor(first, sharedBoundary),
855
+ ) -
856
+ Math.abs(
857
+ getRegionSourceCoordinate(averageSource, second.direction) -
858
+ getRegionAnchor(second, sharedBoundary),
859
+ ) ||
860
+ first.preferredExit.localeCompare(second.preferredExit),
861
+ )[0]!
862
+ }
863
+
864
+ function resolveBusDirection(params: {
865
+ busId: string
866
+ explicitDirection?: FanoutDirection
867
+ preferredExit?: FanoutBorderTarget
868
+ connections: PreparedConnection[]
869
+ sharedBoundary: Bounds
870
+ availableRegions?: AvailableBoundaryRegion[]
871
+ }): { direction: FanoutDirection; preferredExit?: FanoutBorderTarget } {
872
+ const {
873
+ busId,
874
+ explicitDirection,
875
+ preferredExit,
876
+ connections,
877
+ sharedBoundary,
878
+ availableRegions,
879
+ } = params
880
+ if (availableRegions) {
881
+ return resolveAvailableBusExit({
882
+ busId,
883
+ explicitDirection,
884
+ preferredExit,
885
+ connections,
886
+ sharedBoundary,
887
+ availableRegions,
888
+ })
889
+ }
890
+ if (!preferredExit) {
891
+ return {
892
+ direction: explicitDirection ?? inferDirection(busId, connections),
893
+ }
894
+ }
895
+
896
+ const compatibleDirections = getDirectionsForBorderTarget(preferredExit)
897
+ if (explicitDirection) {
898
+ if (!compatibleDirections.includes(explicitDirection)) {
899
+ throw new Error(
900
+ `FanoutSolver: bus "${busId}" direction "${explicitDirection}" is incompatible with preferredExit "${preferredExit}"`,
901
+ )
902
+ }
903
+ return { direction: explicitDirection, preferredExit }
904
+ }
905
+ if (compatibleDirections.length === 1) {
906
+ return { direction: compatibleDirections[0]!, preferredExit }
907
+ }
908
+
909
+ let inferredDirection: FanoutDirection | undefined
910
+ try {
911
+ inferredDirection = inferDirection(busId, connections)
912
+ } catch {
913
+ inferredDirection = undefined
914
+ }
915
+ if (inferredDirection && compatibleDirections.includes(inferredDirection)) {
916
+ return { direction: inferredDirection, preferredExit }
917
+ }
918
+ const averageSource = getAverageSourcePoint(connections)
919
+ return {
920
+ direction: compatibleDirections.toSorted(
921
+ (first, second) =>
922
+ getDistanceToBoundary(averageSource, first, sharedBoundary) -
923
+ getDistanceToBoundary(averageSource, second, sharedBoundary) ||
924
+ first.localeCompare(second),
925
+ )[0]!,
926
+ preferredExit,
927
+ }
928
+ }
929
+
930
+ export function prepareFanoutBuses(
931
+ srj: SimpleRouteJson,
932
+ options: FanoutSolverOptions,
933
+ ): PreparedBus[] {
934
+ const componentGrids = findComponentGrids(srj.obstacles)
935
+ if (componentGrids.length === 0 && srj.connections.length > 0) {
936
+ throw new Error(
937
+ "FanoutSolver: no componentId-tagged pad footprint was found",
938
+ )
939
+ }
940
+ const connectionIndexByName = new Map(
941
+ srj.connections.map((connection, index) => [connection.name, index]),
942
+ )
943
+ const resolvedBusInputs = resolveBusSpecs(srj, options).map((busSpec) => {
944
+ const connections = busSpec.connectionNames.map((connectionName) => {
945
+ const connectionIndex = connectionIndexByName.get(connectionName)
946
+ if (connectionIndex === undefined) {
947
+ throw new Error(
948
+ `FanoutSolver: connection "${connectionName}" is missing from the input`,
949
+ )
950
+ }
951
+ return srj.connections[connectionIndex]!
952
+ })
953
+ const sourceGrid = chooseSourceGrid({
954
+ busSpec,
955
+ connections,
956
+ componentGrids,
957
+ })
958
+ const preparedConnections = connections.map((connection) =>
959
+ prepareConnection({
960
+ connection,
961
+ connectionIndex: connectionIndexByName.get(connection.name)!,
962
+ sourceGrid,
963
+ componentGrids,
964
+ termination: busSpec.termination ?? { type: "boundary" },
965
+ }),
966
+ )
967
+ return { busSpec, sourceGrid, preparedConnections }
968
+ })
969
+ const sourceGrids = [
970
+ ...new Map(
971
+ resolvedBusInputs.map(({ sourceGrid }) => [
972
+ sourceGrid.componentId,
973
+ sourceGrid,
974
+ ]),
975
+ ).values(),
976
+ ]
977
+ const sharedBoundary = resolveSharedBoundary(sourceGrids, options)
978
+ const availableRegions = resolveAvailableBoundaryRegions(
979
+ options.availableCornersAndSides,
980
+ )
981
+ const buses: PreparedBus[] = []
982
+
983
+ for (const {
984
+ busSpec,
985
+ sourceGrid,
986
+ preparedConnections,
987
+ } of resolvedBusInputs) {
988
+ const resolvedExit = resolveBusDirection({
989
+ busId: busSpec.busId,
990
+ explicitDirection:
991
+ busSpec.direction ?? options.busDirections?.[busSpec.busId],
992
+ preferredExit: busSpec.preferredExit,
993
+ connections: preparedConnections,
994
+ sharedBoundary,
995
+ availableRegions:
996
+ busSpec.termination?.type === "plane" ? undefined : availableRegions,
997
+ })
998
+ buses.push({
999
+ busId: busSpec.busId,
1000
+ direction: resolvedExit.direction,
1001
+ preferredExit: resolvedExit.preferredExit,
1002
+ termination: busSpec.termination ?? { type: "boundary" },
1003
+ connections: preparedConnections,
1004
+ componentId: sourceGrid.componentId,
1005
+ componentObstacles: sourceGrid.obstacles,
1006
+ componentBounds: resolveComponentBounds(sourceGrid, options),
1007
+ sharedBoundary,
1008
+ xCoordinates: [...sourceGrid.xCoordinates],
1009
+ yCoordinates: [...sourceGrid.yCoordinates],
1010
+ pitchX: sourceGrid.pitchX,
1011
+ pitchY: sourceGrid.pitchY,
1012
+ })
1013
+ }
1014
+
1015
+ return buses
1016
+ }