@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,488 @@
1
+ import type { SimpleRouteJson } from "@tscircuit/capacity-autorouter"
2
+ import {
3
+ distance,
4
+ distancePointToObstacle,
5
+ distancePointToSegment,
6
+ distanceSegmentToObstacle,
7
+ segmentsAreClear,
8
+ } from "./geometry"
9
+ import { getViaSpanLayers } from "./layer-names"
10
+ import { matchAngularlyOrderedLocalVias } from "./match-angularly-ordered-local-vias"
11
+ import {
12
+ getComponentDogboneViaSiteCandidates,
13
+ matchComponentDogboneViaSites,
14
+ type DogboneViaSiteGeometryRules,
15
+ } from "./match-component-dogbone-via-sites"
16
+ import { routeSingleLayerWithAdaptiveExitsSteps } from "./route-single-layer-adaptive-exits"
17
+ import type {
18
+ Bounds,
19
+ FanoutRoutePlan,
20
+ Point2D,
21
+ PreparedBus,
22
+ PreparedConnection,
23
+ RoutedSegment,
24
+ RoutedVia,
25
+ } from "./types"
26
+
27
+ const EPSILON = 1e-9
28
+ export interface PeripheralSourceEscape {
29
+ connectionIndex: number
30
+ connectionName: string
31
+ segments: RoutedSegment[]
32
+ via: RoutedVia
33
+ }
34
+ export interface PeripheralSourceEscapes {
35
+ sourceEscapes: PeripheralSourceEscape[]
36
+ viaPointsByConnectionIndex: Map<number, Point2D>
37
+ remoteConnectionIndices: Set<number>
38
+ localBus: PreparedBus
39
+ sourceBoundary: Bounds
40
+ }
41
+ export interface PeripheralSourceEscapeParams {
42
+ srj: SimpleRouteJson
43
+ buses: readonly PreparedBus[]
44
+ bus: PreparedBus
45
+ targetLayer: string
46
+ layerNames: string[]
47
+ traceWidth: number
48
+ clearance: number
49
+ viaDiameter: number
50
+ viaHoleDiameter: number
51
+ allowBlindAndBuriedVias?: boolean
52
+ initialViaPoints?: ReadonlyMap<number, Point2D>
53
+ targetPointsByConnectionIndex?: ReadonlyMap<number, Point2D>
54
+ targetLayerByBusId?: ReadonlyMap<string, string>
55
+ }
56
+
57
+ function chamfer(points: Point2D[], amount: number): Point2D[] {
58
+ const result = [points[0]!]
59
+ for (let i = 1; i < points.length - 1; i++) {
60
+ const a = points[i - 1]!,
61
+ p = points[i]!,
62
+ b = points[i + 1]!
63
+ const dx = p.x - a.x,
64
+ dy = p.y - a.y,
65
+ ex = b.x - p.x,
66
+ ey = b.y - p.y
67
+ const before = Math.hypot(dx, dy),
68
+ after = Math.hypot(ex, ey)
69
+ if (before < EPSILON || after < EPSILON) continue
70
+ if (Math.abs(dx * ex + dy * ey) < EPSILON) {
71
+ const trim = Math.min(amount, before / 2, after / 2)
72
+ result.push(
73
+ { x: p.x - (dx / before) * trim, y: p.y - (dy / before) * trim },
74
+ { x: p.x + (ex / after) * trim, y: p.y + (ey / after) * trim },
75
+ )
76
+ } else result.push(p)
77
+ }
78
+ result.push(points.at(-1)!)
79
+ return result.filter(
80
+ (p, i) => i === 0 || distance(p, result[i - 1]!) > EPSILON,
81
+ )
82
+ }
83
+
84
+ /** A deterministic longest peripheral subsequence that preserves target order. */
85
+ function selectOrderedPaths(
86
+ paths: FanoutRoutePlan[],
87
+ ranks: ReadonlyMap<number, number>,
88
+ center: Point2D,
89
+ ): FanoutRoutePlan[] {
90
+ const ordered = paths.toSorted(
91
+ (a, b) =>
92
+ Math.atan2(a.exitPoint.y - center.y, a.exitPoint.x - center.x) -
93
+ Math.atan2(b.exitPoint.y - center.y, b.exitPoint.x - center.x),
94
+ )
95
+ const lexicographicallyEarlier = (
96
+ a: FanoutRoutePlan[],
97
+ b: FanoutRoutePlan[],
98
+ ) => {
99
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
100
+ const difference =
101
+ ranks.get(a[i]!.connectionIndex)! - ranks.get(b[i]!.connectionIndex)!
102
+ if (difference) return difference < 0
103
+ }
104
+ return false
105
+ }
106
+ const better = (a: FanoutRoutePlan[], b: FanoutRoutePlan[]) =>
107
+ a.length > b.length ||
108
+ (a.length === b.length && lexicographicallyEarlier(a, b))
109
+ const suffixes: FanoutRoutePlan[][] = []
110
+ for (let i = ordered.length - 1; i >= 0; i--) {
111
+ let suffix: FanoutRoutePlan[] = []
112
+ for (let j = i + 1; j < ordered.length; j++)
113
+ if (
114
+ ranks.get(ordered[j]!.connectionIndex)! >
115
+ ranks.get(ordered[i]!.connectionIndex)! &&
116
+ better(suffixes[j]!, suffix)
117
+ )
118
+ suffix = suffixes[j]!
119
+ suffixes[i] = [ordered[i]!, ...suffix]
120
+ }
121
+ return suffixes.reduce(
122
+ (best, current) => (better(current, best) ? current : best),
123
+ [],
124
+ )
125
+ }
126
+
127
+ /**
128
+ * Escape an ordered subset on its source layer before assigning the remaining
129
+ * vias. Returned prefixes stop at the first via; the caller must complete and
130
+ * validate the whole atomic bus before accepting any of them.
131
+ */
132
+ export function* routePeripheralSourceEscapesSteps(
133
+ params: PeripheralSourceEscapeParams,
134
+ ): Generator<void, PeripheralSourceEscapes | null, unknown> {
135
+ const { bus, srj, traceWidth: w, clearance: c, viaDiameter: d } = params
136
+ // This adjacent-band construction currently handles a right edge from the
137
+ // upper source perimeter. Other orientations retain the general fallback.
138
+ if (
139
+ bus.termination.type !== "boundary" ||
140
+ bus.exitEdge !== "right" ||
141
+ bus.connections.length < 6 ||
142
+ bus.connections.some((connection) => connection.sourceLayer !== "top") ||
143
+ !(bus.allowedLayers ?? params.layerNames).includes(params.targetLayer) ||
144
+ params.targetLayer === "top"
145
+ )
146
+ return null
147
+ const sourceObstacles = bus.componentObstacles.filter((o) =>
148
+ o.layers.includes("top"),
149
+ )
150
+ if (!sourceObstacles.length) return null
151
+ const padBounds = {
152
+ minX: Math.min(...sourceObstacles.map((o) => o.center.x - o.width / 2)),
153
+ maxX: Math.max(...sourceObstacles.map((o) => o.center.x + o.width / 2)),
154
+ minY: Math.min(...sourceObstacles.map((o) => o.center.y - o.height / 2)),
155
+ maxY: Math.max(...sourceObstacles.map((o) => o.center.y + o.height / 2)),
156
+ }
157
+ const center = {
158
+ x: (padBounds.minX + padBounds.maxX) / 2,
159
+ y: (padBounds.minY + padBounds.maxY) / 2,
160
+ }
161
+ const pitch = w + c,
162
+ padPitch = Math.max(bus.pitchX, bus.pitchY)
163
+ if (!Number.isFinite(padPitch) || padPitch <= 0) return null
164
+ const halfWidth =
165
+ Math.ceil(
166
+ ((padBounds.maxX - padBounds.minX) / 2 + padPitch + pitch) / pitch,
167
+ ) * pitch
168
+ const halfHeight =
169
+ Math.ceil(
170
+ ((padBounds.maxY - padBounds.minY) / 2 + padPitch + pitch) / pitch,
171
+ ) * pitch
172
+ const sourceBoundary = {
173
+ minX: center.x - halfWidth,
174
+ maxX: center.x + halfWidth,
175
+ minY: center.y - halfHeight,
176
+ maxY: center.y + halfHeight,
177
+ }
178
+ if (
179
+ sourceBoundary.minX <= bus.sharedBoundary.minX ||
180
+ sourceBoundary.maxX >= bus.sharedBoundary.maxX ||
181
+ sourceBoundary.minY <= bus.sharedBoundary.minY ||
182
+ sourceBoundary.maxY >= bus.sharedBoundary.maxY
183
+ )
184
+ return null
185
+ const singletonBuses = bus.connections.map((connection, index) => ({
186
+ ...bus,
187
+ busId: `${bus.busId}:source-perimeter:${index}`,
188
+ sharedBoundary: sourceBoundary,
189
+ preferredExit: undefined,
190
+ exitEdge: undefined,
191
+ connections: [
192
+ {
193
+ ...connection,
194
+ exitTargetPoint: undefined,
195
+ hasExplicitLayeredExitTarget: false,
196
+ hasExplicitExitTarget: false,
197
+ },
198
+ ],
199
+ }))
200
+ const paths = yield* routeSingleLayerWithAdaptiveExitsSteps({
201
+ srj,
202
+ buses: singletonBuses,
203
+ traceWidth: w,
204
+ clearance: c,
205
+ })
206
+ if (!paths || paths.length !== bus.connections.length) return null
207
+ const target = (connection: PreparedConnection): Point2D =>
208
+ params.targetPointsByConnectionIndex?.get(connection.connectionIndex) ?? {
209
+ x: bus.sharedBoundary.maxX,
210
+ y: (connection.exitTargetPoint ?? connection.targetPoint).y,
211
+ }
212
+ const targetOrdered = bus.connections.toSorted(
213
+ (a, b) =>
214
+ target(a).y - target(b).y || a.connectionIndex - b.connectionIndex,
215
+ )
216
+ const ranks = new Map(
217
+ targetOrdered.map((connection, i) => [connection.connectionIndex, i]),
218
+ )
219
+ const selected = selectOrderedPaths(paths, ranks, center)
220
+ if (
221
+ selected.length < 3 ||
222
+ selected.length >= bus.connections.length ||
223
+ selected.some(
224
+ (p) =>
225
+ Math.abs(p.exitPoint.y - sourceBoundary.maxY) > EPSILON &&
226
+ (Math.abs(p.exitPoint.x - sourceBoundary.minX) > EPSILON ||
227
+ p.exitPoint.y < center.y),
228
+ )
229
+ )
230
+ return null
231
+ const byIndex = new Map(
232
+ params.buses.flatMap((b) =>
233
+ b.connections.map(
234
+ (connection) =>
235
+ [connection.connectionIndex, { bus: b, connection }] as const,
236
+ ),
237
+ ),
238
+ )
239
+ const viaTargetX = bus.sharedBoundary.maxX - Math.max(padPitch / 2, d / 2 + c)
240
+ const viaTraceDistance = d / 2 + w / 2 + c
241
+ const lastColumn = viaTargetX - viaTraceDistance - 1e-5
242
+ const firstColumn = lastColumn - (selected.length - 1) * pitch
243
+ const lastHeight = bus.sharedBoundary.maxY - pitch
244
+ const firstHeight = lastHeight - (selected.length - 1) * pitch
245
+ if (
246
+ firstColumn <= padBounds.maxX + viaTraceDistance ||
247
+ firstHeight <= sourceBoundary.maxY + pitch
248
+ )
249
+ return null
250
+ const makeVia = (
251
+ connection: PreparedConnection,
252
+ point: Point2D,
253
+ ): RoutedVia => {
254
+ const owner = byIndex.get(connection.connectionIndex)!.bus
255
+ const toLayer =
256
+ owner.busId === bus.busId
257
+ ? params.targetLayer
258
+ : owner.termination.type === "plane"
259
+ ? owner.termination.layer
260
+ : (params.targetLayerByBusId?.get(owner.busId) ??
261
+ (owner.allowedLayers ?? params.layerNames).find(
262
+ (l) => l !== connection.sourceLayer,
263
+ ))
264
+ if (!toLayer)
265
+ throw new Error(
266
+ "FanoutSolver: missing target layer for peripheral via assignment",
267
+ )
268
+ return {
269
+ center: point,
270
+ diameter: d,
271
+ holeDiameter: params.viaHoleDiameter,
272
+ fromLayer: connection.sourceLayer,
273
+ toLayer,
274
+ spanLayers: getViaSpanLayers({
275
+ fromLayer: connection.sourceLayer,
276
+ toLayer,
277
+ layerNames: params.layerNames,
278
+ allowBlindAndBuriedVias: params.allowBlindAndBuriedVias ?? false,
279
+ }),
280
+ }
281
+ }
282
+ const makeEscape = (
283
+ connection: PreparedConnection,
284
+ points: Point2D[],
285
+ ): PeripheralSourceEscape => ({
286
+ connectionIndex: connection.connectionIndex,
287
+ connectionName: connection.connection.name,
288
+ segments: points.slice(1).map((end, i) => ({
289
+ start: points[i]!,
290
+ end,
291
+ width: w,
292
+ layer: connection.sourceLayer,
293
+ })),
294
+ via: makeVia(connection, points.at(-1)!),
295
+ })
296
+ const fixed: PeripheralSourceEscape[] = selected.map((path, i) => {
297
+ const connection = byIndex.get(path.connectionIndex)!.connection,
298
+ exit = target(connection)
299
+ const height = firstHeight + i * pitch,
300
+ column = firstColumn + i * pitch
301
+ return makeEscape(
302
+ connection,
303
+ chamfer(
304
+ [
305
+ path.segments[0]!.start,
306
+ ...path.segments.map((s) => s.end),
307
+ { x: path.exitPoint.x, y: height },
308
+ { x: column, y: height },
309
+ { x: column, y: exit.y },
310
+ { x: viaTargetX, y: exit.y },
311
+ ],
312
+ w,
313
+ ),
314
+ )
315
+ })
316
+ const remoteConnectionIndices = new Set(fixed.map((p) => p.connectionIndex))
317
+ const clears = (
318
+ escape: PeripheralSourceEscape,
319
+ others: readonly PeripheralSourceEscape[],
320
+ ) => {
321
+ const connection = byIndex.get(escape.connectionIndex)!.connection
322
+ if (
323
+ escape.segments.some((segment) =>
324
+ [segment.start, segment.end].some(
325
+ (p) =>
326
+ p.x < bus.sharedBoundary.minX - EPSILON ||
327
+ p.x > bus.sharedBoundary.maxX + EPSILON ||
328
+ p.y < bus.sharedBoundary.minY - EPSILON ||
329
+ p.y > bus.sharedBoundary.maxY + EPSILON,
330
+ ),
331
+ )
332
+ )
333
+ return false
334
+ for (const obstacle of srj.obstacles) {
335
+ if (
336
+ obstacle.layers.some((layer) =>
337
+ escape.via.spanLayers.includes(layer),
338
+ ) &&
339
+ distancePointToObstacle(escape.via.center, obstacle) <
340
+ d / 2 + c - EPSILON
341
+ )
342
+ return false
343
+ if (
344
+ obstacle !== connection.sourceObstacle &&
345
+ escape.segments.some(
346
+ (segment) =>
347
+ obstacle.layers.includes(segment.layer) &&
348
+ distanceSegmentToObstacle(segment, obstacle) < w / 2 + c - EPSILON,
349
+ )
350
+ )
351
+ return false
352
+ }
353
+ for (const other of others) {
354
+ if (escape.connectionIndex === other.connectionIndex) continue
355
+ if (distance(escape.via.center, other.via.center) < d + c - EPSILON)
356
+ return false
357
+ if (
358
+ escape.segments.some(
359
+ (s) =>
360
+ distancePointToSegment(other.via.center, s.start, s.end) <
361
+ viaTraceDistance - EPSILON,
362
+ ) ||
363
+ other.segments.some(
364
+ (s) =>
365
+ distancePointToSegment(escape.via.center, s.start, s.end) <
366
+ viaTraceDistance - EPSILON,
367
+ )
368
+ )
369
+ return false
370
+ if (
371
+ escape.segments.some((a) =>
372
+ other.segments.some((b) => !segmentsAreClear(a, b, c)),
373
+ )
374
+ )
375
+ return false
376
+ }
377
+ return true
378
+ }
379
+ if (fixed.some((escape) => !clears(escape, fixed))) return null
380
+ const remainingBuses = () => {
381
+ const held = new Set(fixed.map((p) => p.connectionIndex))
382
+ return params.buses
383
+ .map((b) => ({
384
+ ...b,
385
+ connections: b.connections.filter(
386
+ (connection) => !held.has(connection.connectionIndex),
387
+ ),
388
+ }))
389
+ .filter((b) => b.connections.length)
390
+ }
391
+ const rules = (): DogboneViaSiteGeometryRules => ({
392
+ viaDiameter: d,
393
+ viaHoleDiameter: params.viaHoleDiameter,
394
+ traceWidth: w,
395
+ clearance: c,
396
+ additionalObstacles: srj.obstacles,
397
+ maximumSearchStates: 300_000,
398
+ blockingSegments: fixed.flatMap((p) =>
399
+ p.segments.map((segment) => ({
400
+ connectionIndex: p.connectionIndex,
401
+ segment,
402
+ })),
403
+ ),
404
+ blockingVias: fixed.map((p) => ({
405
+ connectionIndex: p.connectionIndex,
406
+ ...p.via,
407
+ })),
408
+ preferredViaPointsByConnectionIndex: params.initialViaPoints,
409
+ })
410
+ let remaining = remainingBuses()
411
+ const available = new Set(
412
+ getComponentDogboneViaSiteCandidates(remaining, rules()).map(
413
+ (p) => p.connectionIndex,
414
+ ),
415
+ )
416
+ for (const owner of remaining)
417
+ for (const connection of owner.connections) {
418
+ if (available.has(connection.connectionIndex)) continue
419
+ if (owner.termination.type !== "plane") return null
420
+ const source = connection.sourcePoint,
421
+ obstacle = connection.sourceObstacle
422
+ const directions = [
423
+ { x: 0, y: 1, gap: padBounds.maxY - source.y },
424
+ { x: -1, y: 0, gap: source.x - padBounds.minX },
425
+ { x: 0, y: -1, gap: source.y - padBounds.minY },
426
+ { x: 1, y: 0, gap: padBounds.maxX - source.x },
427
+ ].sort((a, b) => a.gap - b.gap)
428
+ let additional: PeripheralSourceEscape | null = null
429
+ for (const direction of directions) {
430
+ for (let step = 0; step < 8; step++) {
431
+ const offset =
432
+ (direction.x ? obstacle.width : obstacle.height) / 2 +
433
+ d / 2 +
434
+ c +
435
+ 1e-5 +
436
+ step * w
437
+ const candidate = makeEscape(connection, [
438
+ source,
439
+ {
440
+ x: source.x + direction.x * offset,
441
+ y: source.y + direction.y * offset,
442
+ },
443
+ ])
444
+ if (clears(candidate, fixed)) {
445
+ additional = candidate
446
+ break
447
+ }
448
+ }
449
+ if (additional) break
450
+ }
451
+ if (!additional) return null
452
+ fixed.push(additional)
453
+ }
454
+ remaining = remainingBuses()
455
+ const geometryRules = rules()
456
+ const initial = matchComponentDogboneViaSites(remaining, geometryRules)
457
+ if (!initial) return null
458
+ yield
459
+ const matched = matchAngularlyOrderedLocalVias({
460
+ buses: remaining,
461
+ busId: bus.busId,
462
+ rules: { ...geometryRules, preferredViaPointsByConnectionIndex: initial },
463
+ })
464
+ if (!matched) return null
465
+ const sourceEscapes = [
466
+ ...fixed,
467
+ ...remaining.flatMap((owner) =>
468
+ owner.connections.map((connection) =>
469
+ makeEscape(connection, [
470
+ connection.sourcePoint,
471
+ matched.get(connection.connectionIndex)!,
472
+ ]),
473
+ ),
474
+ ),
475
+ ]
476
+ if (sourceEscapes.some((escape) => !clears(escape, sourceEscapes)))
477
+ return null
478
+ const viaPointsByConnectionIndex = new Map(
479
+ sourceEscapes.map((escape) => [escape.connectionIndex, escape.via.center]),
480
+ )
481
+ return {
482
+ sourceEscapes,
483
+ viaPointsByConnectionIndex,
484
+ remoteConnectionIndices,
485
+ localBus: remaining.find((b) => b.busId === bus.busId)!,
486
+ sourceBoundary,
487
+ }
488
+ }