@tscircuit/fanout-solver 0.0.64 → 0.0.65

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,257 @@
1
+ import type { SimpleRouteJson } from "@tscircuit/capacity-autorouter"
2
+ import {
3
+ distance,
4
+ distancePointToSegment,
5
+ distanceSegmentToSegment,
6
+ } from "./geometry"
7
+ import { fanoutPlansAreClear } from "./route-bus"
8
+ import {
9
+ buildViaMinimalWindingPlan,
10
+ routeViaMinimalWindingAlternativesSteps,
11
+ type RouteViaMinimalWindingProgress,
12
+ } from "./route-via-minimal-winding"
13
+ import type { Bounds, FanoutRoutePlan, Point2D, PreparedBus } from "./types"
14
+
15
+ import type { PeripheralSourceEscape } from "./route-peripheral-source-escapes"
16
+ export type { PeripheralSourceEscape } from "./route-peripheral-source-escapes"
17
+
18
+ /** Join ordered inner-layer source escapes to nested perimeter lanes. */
19
+ export function* routeStagedPerimeterBusSteps(params: {
20
+ srj: SimpleRouteJson
21
+ bus: PreparedBus
22
+ targetLayer: string
23
+ layerNames: string[]
24
+ traceWidth: number
25
+ clearance: number
26
+ viaDiameter: number
27
+ viaHoleDiameter: number
28
+ sourceBoundary: Bounds
29
+ sourceEscapes: readonly PeripheralSourceEscape[]
30
+ remoteConnectionIndices: ReadonlySet<number>
31
+ }): Generator<RouteViaMinimalWindingProgress, FanoutRoutePlan[] | null, void> {
32
+ const {
33
+ srj,
34
+ bus,
35
+ targetLayer,
36
+ sourceBoundary,
37
+ sourceEscapes,
38
+ remoteConnectionIndices,
39
+ traceWidth: width,
40
+ clearance,
41
+ } = params
42
+ if (bus.exitEdge !== "right" || bus.termination.type !== "boundary")
43
+ return null
44
+ const pitch = width + clearance,
45
+ padPitch = Math.min(bus.pitchX, bus.pitchY)
46
+ const byIndex = new Map(sourceEscapes.map((s) => [s.connectionIndex, s]))
47
+ const terminals = bus.connections
48
+ .map((connection) => {
49
+ const source = byIndex.get(connection.connectionIndex)
50
+ if (!source)
51
+ throw new Error(
52
+ `FanoutSolver: missing source escape for ${connection.connection.name}`,
53
+ )
54
+ const target = connection.exitTargetPoint ?? connection.targetPoint
55
+ return {
56
+ connection,
57
+ viaPoint: source.via.center,
58
+ exitPoint: { x: bus.sharedBoundary.maxX, y: target.y },
59
+ }
60
+ })
61
+ .sort((a, b) => a.exitPoint.y - b.exitPoint.y)
62
+ const local = terminals.filter(
63
+ (t) => !remoteConnectionIndices.has(t.connection.connectionIndex),
64
+ )
65
+ if (local.length < 2 || local.length === terminals.length) return null
66
+ const minPadX = Math.min(
67
+ ...bus.componentObstacles.map((o) => o.center.x - o.width / 2),
68
+ )
69
+ const nearPort = Math.max(...local.map((t) => t.viaPoint.x)) - width / 2
70
+ const farPort = minPadX + params.viaDiameter / 2
71
+ if (nearPort - farPort < (local.length - 1) * pitch) return null
72
+ const rowY = sourceBoundary.maxY
73
+ const stageBoundary = {
74
+ minX: minPadX - padPitch,
75
+ maxX: Math.max(...local.map((t) => t.viaPoint.x)) + 2 * padPitch,
76
+ minY: Math.min(...local.map((t) => t.viaPoint.y)) - 3 * padPitch,
77
+ maxY: rowY,
78
+ }
79
+ const localIndices = new Set(local.map((t) => t.connection.connectionIndex))
80
+ const stageTerminals = local.map((t, i) => ({
81
+ ...t,
82
+ exitPoint: {
83
+ x: nearPort + ((farPort - nearPort) * i) / (local.length - 1),
84
+ y: rowY,
85
+ },
86
+ }))
87
+ const stageBus: PreparedBus = {
88
+ ...bus,
89
+ exitEdge: "top",
90
+ direction: "up",
91
+ preferredExit: undefined,
92
+ sharedBoundary: stageBoundary,
93
+ connections: local.map((t) => t.connection),
94
+ }
95
+ const sourcePaths = new Map(
96
+ sourceEscapes.map((s) => [
97
+ s.connectionIndex,
98
+ [s.segments[0]!.start, ...s.segments.map((s) => s.end)],
99
+ ]),
100
+ )
101
+ let prefixes: FanoutRoutePlan[] | undefined
102
+ for (const laneBias of [-1, 0, 1] as const) {
103
+ const alternatives = yield* routeViaMinimalWindingAlternativesSteps(
104
+ {
105
+ ...params,
106
+ bus: stageBus,
107
+ srj: { ...srj, bounds: stageBoundary },
108
+ acceptedPlans: [],
109
+ terminals: stageTerminals,
110
+ sourceEscapePaths: sourcePaths,
111
+ reservedVias: sourceEscapes
112
+ .filter((s) => !localIndices.has(s.connectionIndex))
113
+ .map((s) => ({ connectionName: s.connectionName, via: s.via })),
114
+ allowBlindAndBuriedVias: false,
115
+ allowSameNetMerges: false,
116
+ gridStepDivisor: 2,
117
+ gridStep: pitch / 4,
118
+ alignGridToPads: true,
119
+ maximumRouteOrderAttempts: 1,
120
+ routeOrder: local.map((_, i) => local.length - i - 1),
121
+ laneBias,
122
+ },
123
+ 1,
124
+ false,
125
+ )
126
+ if (alternatives.length) {
127
+ prefixes = alternatives[0]
128
+ break
129
+ }
130
+ }
131
+ if (!prefixes) return null
132
+ const prefixByIndex = new Map(prefixes.map((p) => [p.connectionIndex, p]))
133
+ const roofStart = rowY + padPitch,
134
+ columnStart =
135
+ bus.sharedBoundary.maxX -
136
+ (local.length + 1) * pitch -
137
+ 2 * params.viaDiameter
138
+ if (
139
+ roofStart + (local.length - 1) * pitch + width / 2 >
140
+ bus.sharedBoundary.maxY
141
+ )
142
+ return null
143
+ let lane = 0
144
+ const plans: FanoutRoutePlan[] = []
145
+ for (const terminal of terminals) {
146
+ const source = byIndex.get(terminal.connection.connectionIndex)!,
147
+ prefix = prefixByIndex.get(terminal.connection.connectionIndex)
148
+ let points: Point2D[]
149
+ if (prefix) {
150
+ const ss = prefix.segments.filter((s) => s.layer === targetLayer)
151
+ const last = ss.at(-1)!,
152
+ dx = last.end.x - last.start.x,
153
+ dy = last.end.y - last.start.y
154
+ if (dy <= 0 || Math.abs(dx) > dy + 1e-7) return null
155
+ const port = last.end,
156
+ roof = roofStart + lane * pitch,
157
+ column = columnStart + lane * pitch
158
+ lane++
159
+ const extension = chamferRightAngles(
160
+ [
161
+ port,
162
+ { x: port.x, y: roof },
163
+ { x: column, y: roof },
164
+ { x: column, y: terminal.exitPoint.y },
165
+ terminal.exitPoint,
166
+ ],
167
+ width,
168
+ )
169
+ points = [ss[0]!.start, ...ss.map((s) => s.end), ...extension.slice(1)]
170
+ } else points = [source.via.center, terminal.exitPoint]
171
+ plans.push(
172
+ buildViaMinimalWindingPlan({
173
+ ...params,
174
+ bus,
175
+ terminal,
176
+ targetLayerPoints: points,
177
+ sourceEscapePoints: sourcePaths.get(
178
+ terminal.connection.connectionIndex,
179
+ ),
180
+ allowBlindAndBuriedVias: false,
181
+ }),
182
+ )
183
+ }
184
+ const lengths = plans.map((p) => p.length)
185
+ if (
186
+ bus.maxLengthSkew !== undefined &&
187
+ Math.max(...lengths) - Math.min(...lengths) > bus.maxLengthSkew + 1e-6
188
+ )
189
+ return null
190
+ if (
191
+ !fanoutPlansAreClear({
192
+ plans,
193
+ srj,
194
+ sharedBoundary: bus.sharedBoundary,
195
+ clearance,
196
+ allowBlindAndBuriedVias: false,
197
+ allowSameNetMerges: false,
198
+ })
199
+ )
200
+ return null
201
+ // A later bus must retain the source vias and source-layer copper reserved above.
202
+ for (const plan of plans)
203
+ for (const source of sourceEscapes) {
204
+ if (plan.connectionIndex === source.connectionIndex) continue
205
+ for (const segment of plan.segments) {
206
+ if (
207
+ source.via.spanLayers.includes(segment.layer) &&
208
+ distancePointToSegment(
209
+ source.via.center,
210
+ segment.start,
211
+ segment.end,
212
+ ) <
213
+ source.via.diameter / 2 + segment.width / 2 + clearance - 1e-7
214
+ )
215
+ return null
216
+ for (const other of source.segments)
217
+ if (
218
+ segment.layer === other.layer &&
219
+ distanceSegmentToSegment(
220
+ segment.start,
221
+ segment.end,
222
+ other.start,
223
+ other.end,
224
+ ) <
225
+ (segment.width + other.width) / 2 + clearance - 1e-7
226
+ )
227
+ return null
228
+ }
229
+ }
230
+ return plans
231
+ }
232
+
233
+ function chamferRightAngles(points: Point2D[], trim: number): Point2D[] {
234
+ // A boundary track can coincide with its roof, making the vertical descent
235
+ // disappear. Remove that repeated corner before normalizing either leg.
236
+ const distinct = points.filter(
237
+ (point, index) => index === 0 || distance(point, points[index - 1]!) > 1e-9,
238
+ )
239
+ return distinct.flatMap((p, i) => {
240
+ if (i === 0 || i === distinct.length - 1) return [p]
241
+ const a = distinct[i - 1]!,
242
+ b = distinct[i + 1]!,
243
+ d = Math.min(trim, distance(a, p) / 3, distance(p, b) / 3)
244
+ if (Math.abs((p.x - a.x) * (b.x - p.x) + (p.y - a.y) * (b.y - p.y)) > 1e-9)
245
+ return [p]
246
+ return [
247
+ {
248
+ x: p.x + ((a.x - p.x) * d) / distance(a, p),
249
+ y: p.y + ((a.y - p.y) * d) / distance(a, p),
250
+ },
251
+ {
252
+ x: p.x + ((b.x - p.x) * d) / distance(p, b),
253
+ y: p.y + ((b.y - p.y) * d) / distance(p, b),
254
+ },
255
+ ]
256
+ })
257
+ }
@@ -66,6 +66,14 @@ export interface RouteViaMinimalWindingParams {
66
66
  softReservedVias?: readonly ViaMinimalWindingReservedVia[]
67
67
  /** Use a finer uniform grid for narrow channels between reserved vias. */
68
68
  gridStepDivisor?: 1 | 2
69
+ /** Exact grid spacing for staged routing through narrow via channels. */
70
+ gridStep?: number
71
+ /** Deterministic terminal order for a caller that has ordered escape ports. */
72
+ routeOrder?: readonly number[]
73
+ /** Side preference for a caller-supplied terminal order. */
74
+ laneBias?: -1 | 0 | 1
75
+ /** Actual copper before the first via when source escape has multiple bends. */
76
+ sourceEscapePaths?: ReadonlyMap<number, readonly Point2D[]>
69
77
  /** Bias bounded fixed-site searches toward the remote target band. */
70
78
  preferTargetDirectedLaneBias?: boolean
71
79
  /** Internal path-only mode used before a boundary-side via is appended. */
@@ -473,11 +481,12 @@ function getBlockingCopper(params: {
473
481
  }
474
482
  }
475
483
 
476
- function buildPlan(params: {
484
+ export function buildViaMinimalWindingPlan(params: {
477
485
  bus: PreparedBus
478
486
  terminal: ViaMinimalWindingTerminal
479
487
  targetLayer: string
480
488
  targetLayerPoints: Point2D[]
489
+ sourceEscapePoints?: readonly Point2D[]
481
490
  layerNames: string[]
482
491
  traceWidth: number
483
492
  viaDiameter: number
@@ -500,12 +509,24 @@ function buildPlan(params: {
500
509
  x: connection.sourcePoint.x,
501
510
  y: connection.sourcePoint.y,
502
511
  }
503
- const sourceSegment: RoutedSegment = {
504
- start: sourcePoint,
505
- end: terminal.viaPoint,
506
- width: traceWidth,
507
- layer: connection.sourceLayer,
512
+ const sourcePoints = params.sourceEscapePoints ?? [
513
+ sourcePoint,
514
+ terminal.viaPoint,
515
+ ]
516
+ if (
517
+ sourcePoints.length < 2 ||
518
+ distance(sourcePoints[0]!, sourcePoint) > EPSILON ||
519
+ distance(sourcePoints.at(-1)!, terminal.viaPoint) > EPSILON
520
+ ) {
521
+ throw new Error(
522
+ "FanoutSolver: source escape must connect the source pad to its first via",
523
+ )
508
524
  }
525
+ const sourceSegments = getSegments(
526
+ [...sourcePoints],
527
+ traceWidth,
528
+ connection.sourceLayer,
529
+ )
509
530
  const hasSourceDogbone = distance(sourcePoint, terminal.viaPoint) > EPSILON
510
531
  const changesLayer = connection.sourceLayer !== targetLayer
511
532
  const targetSegments = getSegments(targetLayerPoints, traceWidth, targetLayer)
@@ -535,12 +556,12 @@ function buildPlan(params: {
535
556
  },
536
557
  ...(hasSourceDogbone
537
558
  ? [
538
- {
559
+ ...sourcePoints.slice(1).map((point) => ({
539
560
  route_type: "wire" as const,
540
- ...terminal.viaPoint,
561
+ ...point,
541
562
  width: traceWidth,
542
563
  layer: connection.sourceLayer,
543
- },
564
+ })),
544
565
  ]
545
566
  : []),
546
567
  ...(changesLayer
@@ -573,7 +594,7 @@ function buildPlan(params: {
573
594
  sourcePointIndex: connection.sourcePointIndex,
574
595
  })
575
596
  const segments = [
576
- ...(hasSourceDogbone ? [sourceSegment] : []),
597
+ ...(hasSourceDogbone ? sourceSegments : []),
577
598
  ...targetSegments,
578
599
  ]
579
600
  const cornerBandSide = getCornerBandSide(bus.exitEdge, bus.preferredExit)
@@ -608,6 +629,9 @@ function buildPlan(params: {
608
629
  route,
609
630
  },
610
631
  segments,
632
+ ...(sourceSegments.length > 1
633
+ ? { sourceEscapeSegmentCount: sourceSegments.length }
634
+ : {}),
611
635
  via: changesLayer ? via : undefined,
612
636
  length: segments.reduce(
613
637
  (total, segment) => total + distance(segment.start, segment.end),
@@ -680,9 +704,11 @@ export function* routeViaMinimalWindingAlternativesSteps(
680
704
  const pitch = Math.min(bus.pitchX, bus.pitchY)
681
705
  const alignGridToPitch =
682
706
  alignGridToPads && gridStepDivisor === 2 && Number.isFinite(pitch)
683
- const gridStep = alignGridToPitch
684
- ? pitch / (2 * Math.ceil(pitch / (2 * baseGridStep)))
685
- : baseGridStep
707
+ const gridStep =
708
+ params.gridStep ??
709
+ (alignGridToPitch
710
+ ? pitch / (2 * Math.ceil(pitch / (2 * baseGridStep)))
711
+ : baseGridStep)
686
712
  if (!Number.isFinite(gridStep) || gridStep <= 0) return []
687
713
  const { minX, maxX, minY, maxY } = bus.sharedBoundary
688
714
  const originX = bus.xCoordinates[0] ?? minX
@@ -1404,26 +1430,45 @@ export function* routeViaMinimalWindingAlternativesSteps(
1404
1430
  Math.max(...viaTracks) < Math.min(...targetTracks) - EPSILON
1405
1431
  const viasAreAfterTargets =
1406
1432
  Math.min(...viaTracks) > Math.max(...targetTracks) + EPSILON
1407
- const laneBiases = preferTargetDirectedLaneBias
1408
- ? viasAreBeforeTargets
1409
- ? ([0, 1, -1] as const)
1410
- : viasAreAfterTargets
1411
- ? ([0, -1, 1] as const)
1412
- : bus.direction === boundaryDirection &&
1413
- meanTargetTrack > meanViaTrack + EPSILON
1414
- ? ([1, 0, -1] as const)
1433
+ const laneBiases = params.routeOrder
1434
+ ? [params.laneBias ?? 0]
1435
+ : preferTargetDirectedLaneBias
1436
+ ? viasAreBeforeTargets
1437
+ ? ([0, 1, -1] as const)
1438
+ : viasAreAfterTargets
1439
+ ? ([0, -1, 1] as const)
1415
1440
  : bus.direction === boundaryDirection &&
1416
- meanTargetTrack < meanViaTrack - EPSILON
1417
- ? ([-1, 0, 1] as const)
1418
- : ([0, 1, -1] as const)
1419
- : viasAreBeforeTargets
1420
- ? ([1, 0, -1] as const)
1421
- : viasAreAfterTargets
1422
- ? ([-1, 0, 1] as const)
1423
- : ([0, 1, -1] as const)
1441
+ meanTargetTrack > meanViaTrack + EPSILON
1442
+ ? ([1, 0, -1] as const)
1443
+ : bus.direction === boundaryDirection &&
1444
+ meanTargetTrack < meanViaTrack - EPSILON
1445
+ ? ([-1, 0, 1] as const)
1446
+ : ([0, 1, -1] as const)
1447
+ : viasAreBeforeTargets
1448
+ ? ([1, 0, -1] as const)
1449
+ : viasAreAfterTargets
1450
+ ? ([-1, 0, 1] as const)
1451
+ : ([0, 1, -1] as const)
1424
1452
  const initialRouteOrderFactories: Array<
1425
1453
  () => readonly ViaMinimalWindingTerminal[]
1426
1454
  > = []
1455
+ if (params.routeOrder) {
1456
+ if (
1457
+ params.routeOrder.length !== terminals.length ||
1458
+ new Set(params.routeOrder).size !== terminals.length ||
1459
+ params.routeOrder.some(
1460
+ (index) =>
1461
+ !Number.isInteger(index) || index < 0 || index >= terminals.length,
1462
+ )
1463
+ ) {
1464
+ throw new Error(
1465
+ "FanoutSolver: routeOrder must contain every terminal index exactly once",
1466
+ )
1467
+ }
1468
+ initialRouteOrderFactories.push(() =>
1469
+ params.routeOrder!.map((index) => terminals[index]!),
1470
+ )
1471
+ }
1427
1472
  if (alignGridToPads && preferTargetDirectedLaneBias && viasAreBeforeTargets) {
1428
1473
  initialRouteOrderFactories.push(() => targetOrderedTerminals)
1429
1474
  }
@@ -1652,11 +1697,14 @@ export function* routeViaMinimalWindingAlternativesSteps(
1652
1697
  `FanoutSolver: via-minimal winding route omitted "${terminal.connection.connection.name}"`,
1653
1698
  )
1654
1699
  }
1655
- return buildPlan({
1700
+ return buildViaMinimalWindingPlan({
1656
1701
  bus,
1657
1702
  terminal,
1658
1703
  targetLayer,
1659
1704
  targetLayerPoints,
1705
+ sourceEscapePoints: params.sourceEscapePaths?.get(
1706
+ terminal.connection.connectionIndex,
1707
+ ),
1660
1708
  layerNames,
1661
1709
  traceWidth,
1662
1710
  viaDiameter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.64",
3
+ "version": "0.0.65",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",