@tscircuit/fanout-solver 0.0.64 → 0.0.66

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,579 @@
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 {
11
+ getComponentDogboneViaSiteCandidates,
12
+ matchComponentDogboneViaSites,
13
+ type DogboneViaSiteGeometryRules,
14
+ } from "./match-component-dogbone-via-sites"
15
+ import { routeSingleLayerWithAdaptiveExitsSteps } from "./route-single-layer-adaptive-exits"
16
+ import type {
17
+ PeripheralSourceEscape,
18
+ PeripheralSourceEscapeParams,
19
+ } from "./route-peripheral-source-escapes"
20
+ import type {
21
+ Bounds,
22
+ FanoutRoutePlan,
23
+ Point2D,
24
+ PreparedBus,
25
+ PreparedConnection,
26
+ } from "./types"
27
+
28
+ const EPS = 1e-7
29
+ export interface SplitPerimeterSources {
30
+ sourceEscapes: PeripheralSourceEscape[]
31
+ sourceBoundary: Bounds
32
+ remoteConnectionIndices: Set<number>
33
+ bottomRemoteConnectionIndices: Set<number>
34
+ lowerConnectionIndices: number[]
35
+ viaPointsByConnectionIndex: Map<number, Point2D>
36
+ }
37
+
38
+ export function chamferSplitPerimeter(
39
+ points: readonly Point2D[],
40
+ width: number,
41
+ ): Point2D[] {
42
+ const ps = points.filter((p, i) => !i || distance(p, points[i - 1]!) > 1e-9)
43
+ return ps.flatMap((p, i) => {
44
+ if (!i || i === ps.length - 1) return [p]
45
+ const a = ps[i - 1]!,
46
+ b = ps[i + 1]!,
47
+ A = distance(a, p),
48
+ B = distance(p, b)
49
+ if (Math.abs((p.x - a.x) * (b.x - p.x) + (p.y - a.y) * (b.y - p.y)) > 1e-9)
50
+ return [p]
51
+ const k = Math.min(width, A / 3, B / 3)
52
+ return [
53
+ { x: p.x + ((a.x - p.x) * k) / A, y: p.y + ((a.y - p.y) * k) / A },
54
+ { x: p.x + ((b.x - p.x) * k) / B, y: p.y + ((b.y - p.y) * k) / B },
55
+ ]
56
+ })
57
+ }
58
+
59
+ /** Preserve ordinary via domains while choosing an ordered peripheral subset. */
60
+ function orderedPhysicalSubset(
61
+ paths: FanoutRoutePlan[],
62
+ params: PeripheralSourceEscapeParams,
63
+ center: Point2D,
64
+ ): FanoutRoutePlan[] | null {
65
+ if (paths.length > 28) return null
66
+ const { traceWidth: w, clearance: c, viaDiameter: d } = params
67
+ const all = params.buses.flatMap((b) => b.connections)
68
+ const candidates = getComponentDogboneViaSiteCandidates(params.buses, {
69
+ ...params,
70
+ additionalObstacles: params.srj.obstacles,
71
+ })
72
+ const own = new Map(paths.map((p, i) => [p.connectionIndex, i]))
73
+ const domains = all.map((connection) => ({
74
+ own: own.get(connection.connectionIndex),
75
+ masks: candidates
76
+ .filter((v) => v.connectionIndex === connection.connectionIndex)
77
+ .map((v) => {
78
+ const s = {
79
+ start: connection.sourcePoint,
80
+ end: v.point,
81
+ width: w,
82
+ layer: connection.sourceLayer,
83
+ }
84
+ let mask = 0
85
+ paths.forEach((p, i) => {
86
+ if (p.connectionIndex === connection.connectionIndex) return
87
+ if (
88
+ distance(v.point, p.exitPoint) < d + c - EPS ||
89
+ distancePointToSegment(p.exitPoint, s.start, s.end) <
90
+ d / 2 + w / 2 + c - EPS ||
91
+ p.segments.some(
92
+ (t) =>
93
+ distancePointToSegment(v.point, t.start, t.end) <
94
+ d / 2 + w / 2 + c - EPS || !segmentsAreClear(s, t, c),
95
+ )
96
+ )
97
+ mask |= 1 << i
98
+ })
99
+ return mask
100
+ }),
101
+ }))
102
+ const angle = (p: Point2D) => Math.atan2(p.y - center.y, p.x - center.x)
103
+ const physical = paths
104
+ .map((_, i) => i)
105
+ .sort((a, b) => angle(paths[b]!.exitPoint) - angle(paths[a]!.exitPoint))
106
+ const targets = params.bus.connections.toSorted(
107
+ (a, b) =>
108
+ angle(b.exitTargetPoint ?? b.targetPoint) -
109
+ angle(a.exitTargetPoint ?? a.targetPoint),
110
+ )
111
+ const ranks = new Map(targets.map((c, i) => [c.connectionIndex, i]))
112
+ const feasible = (mask: number) =>
113
+ domains.every(
114
+ (row) =>
115
+ (row.own !== undefined && mask & (1 << row.own)) ||
116
+ row.masks.some((blocked) => !(blocked & mask)),
117
+ )
118
+ let best: number[] = []
119
+ let work = 0
120
+ for (let offset = 0; offset < paths.length; offset++) {
121
+ const sequence = [...physical.slice(offset), ...physical.slice(0, offset)]
122
+ const visit = (
123
+ at: number,
124
+ last: number,
125
+ mask: number,
126
+ selected: number[],
127
+ ) => {
128
+ if (
129
+ ++work > 1_000_000 ||
130
+ selected.length + sequence.length - at < best.length
131
+ )
132
+ return
133
+ if (at === sequence.length) {
134
+ if (selected.length > best.length && feasible(mask))
135
+ best = [...selected]
136
+ return
137
+ }
138
+ const i = sequence[at]!,
139
+ rank = ranks.get(paths[i]!.connectionIndex)!
140
+ if (rank > last) visit(at + 1, rank, mask | (1 << i), [...selected, i])
141
+ visit(at + 1, last, mask, selected)
142
+ }
143
+ visit(0, -1, 0, [])
144
+ }
145
+ return best.length >= 3 ? best.map((i) => paths[i]!) : null
146
+ }
147
+
148
+ /** A source-layer split allows a reversed lower group to use the opposite perimeter. */
149
+ export function* routeSplitPerimeterSourceEscapesSteps(
150
+ params: PeripheralSourceEscapeParams,
151
+ ): Generator<void, SplitPerimeterSources | null, unknown> {
152
+ const { bus, srj, traceWidth: w, clearance: c, viaDiameter: d } = params
153
+ if (
154
+ bus.termination.type !== "boundary" ||
155
+ bus.exitEdge !== "left" ||
156
+ bus.connections.length < 8 ||
157
+ bus.connections.length > 28 ||
158
+ params.targetLayer === "top" ||
159
+ bus.connections.some((c) => c.sourceLayer !== "top")
160
+ )
161
+ return null
162
+ const padPitch = Math.min(bus.pitchX, bus.pitchY),
163
+ pitch = w + c
164
+ if (!Number.isFinite(padPitch) || padPitch <= 0) return null
165
+ const os = bus.componentObstacles.filter((o) => o.layers.includes("top"))
166
+ const bounds = {
167
+ minX: Math.min(...os.map((o) => o.center.x - o.width / 2)),
168
+ maxX: Math.max(...os.map((o) => o.center.x + o.width / 2)),
169
+ minY: Math.min(...os.map((o) => o.center.y - o.height / 2)),
170
+ maxY: Math.max(...os.map((o) => o.center.y + o.height / 2)),
171
+ }
172
+ const center = {
173
+ x: (bounds.minX + bounds.maxX) / 2,
174
+ y: (bounds.minY + bounds.maxY) / 2,
175
+ }
176
+ const halfX =
177
+ Math.ceil(((bounds.maxX - bounds.minX) / 2 + padPitch + pitch) / pitch) *
178
+ pitch,
179
+ halfY =
180
+ Math.ceil(((bounds.maxY - bounds.minY) / 2 + padPitch + pitch) / pitch) *
181
+ pitch
182
+ const sourceBoundary = {
183
+ minX: center.x - halfX,
184
+ maxX: center.x + halfX,
185
+ minY: center.y - halfY,
186
+ maxY: center.y + halfY,
187
+ }
188
+ if (
189
+ sourceBoundary.minX <= bus.sharedBoundary.minX ||
190
+ sourceBoundary.maxY >= bus.sharedBoundary.maxY ||
191
+ sourceBoundary.minY <= bus.sharedBoundary.minY
192
+ )
193
+ return null
194
+ const byIndex = new Map(
195
+ params.buses.flatMap((owner) =>
196
+ owner.connections.map(
197
+ (connection) =>
198
+ [connection.connectionIndex, { owner, connection }] as const,
199
+ ),
200
+ ),
201
+ )
202
+ const target = (connection: PreparedConnection) =>
203
+ params.targetPointsByConnectionIndex?.get(connection.connectionIndex) ?? {
204
+ x: bus.sharedBoundary.minX,
205
+ y: (connection.exitTargetPoint ?? connection.targetPoint).y,
206
+ }
207
+ const singleton = (connections: PreparedConnection[]) =>
208
+ connections.map((connection, i) => ({
209
+ ...bus,
210
+ busId: `${bus.busId}:split-source:${i}`,
211
+ sharedBoundary: sourceBoundary,
212
+ preferredExit: undefined,
213
+ exitEdge: undefined,
214
+ connections: [
215
+ {
216
+ ...connection,
217
+ exitTargetPoint: undefined,
218
+ hasExplicitLayeredExitTarget: false,
219
+ hasExplicitExitTarget: false,
220
+ },
221
+ ],
222
+ }))
223
+ const paths = yield* routeSingleLayerWithAdaptiveExitsSteps({
224
+ srj,
225
+ buses: singleton(bus.connections),
226
+ traceWidth: w,
227
+ clearance: c,
228
+ })
229
+ if (!paths || paths.length !== bus.connections.length) return null
230
+ const selected = orderedPhysicalSubset(paths, params, center)
231
+ if (!selected) return null
232
+ const make = (
233
+ connection: PreparedConnection,
234
+ ps: Point2D[],
235
+ ): PeripheralSourceEscape => {
236
+ const owner = byIndex.get(connection.connectionIndex)!.owner
237
+ const toLayer =
238
+ owner === bus
239
+ ? params.targetLayer
240
+ : owner.termination.type === "plane"
241
+ ? owner.termination.layer
242
+ : (params.targetLayerByBusId?.get(owner.busId) ??
243
+ (owner.allowedLayers ?? params.layerNames).find(
244
+ (l) => l !== connection.sourceLayer,
245
+ ))
246
+ if (!toLayer)
247
+ throw Error("FanoutSolver: split source has no legal target layer")
248
+ return {
249
+ connectionIndex: connection.connectionIndex,
250
+ connectionName: connection.connection.name,
251
+ segments: ps.slice(1).map((end, i) => ({
252
+ start: ps[i]!,
253
+ end,
254
+ width: w,
255
+ layer: connection.sourceLayer,
256
+ })),
257
+ via: {
258
+ center: ps.at(-1)!,
259
+ diameter: d,
260
+ holeDiameter: params.viaHoleDiameter,
261
+ fromLayer: connection.sourceLayer,
262
+ toLayer,
263
+ spanLayers: getViaSpanLayers({
264
+ fromLayer: connection.sourceLayer,
265
+ toLayer,
266
+ layerNames: params.layerNames,
267
+ allowBlindAndBuriedVias: false,
268
+ }),
269
+ },
270
+ }
271
+ }
272
+ const clear = (
273
+ p: PeripheralSourceEscape,
274
+ others: readonly PeripheralSourceEscape[],
275
+ ) => {
276
+ const conn = byIndex.get(p.connectionIndex)!.connection
277
+ if (
278
+ p.segments.some((s) =>
279
+ [s.start, s.end].some(
280
+ (q) =>
281
+ q.x < bus.sharedBoundary.minX - EPS ||
282
+ q.x > bus.sharedBoundary.maxX + EPS ||
283
+ q.y < bus.sharedBoundary.minY - EPS ||
284
+ q.y > bus.sharedBoundary.maxY + EPS,
285
+ ),
286
+ )
287
+ )
288
+ return false
289
+ for (const o of srj.obstacles) {
290
+ if (
291
+ o.layers.some((l) => p.via.spanLayers.includes(l)) &&
292
+ distancePointToObstacle(p.via.center, o) < d / 2 + c - EPS
293
+ )
294
+ return false
295
+ if (
296
+ o !== conn.sourceObstacle &&
297
+ p.segments.some(
298
+ (s) =>
299
+ o.layers.includes(s.layer) &&
300
+ distanceSegmentToObstacle(s, o) < w / 2 + c - EPS,
301
+ )
302
+ )
303
+ return false
304
+ }
305
+ return others.every(
306
+ (q) =>
307
+ q.connectionIndex === p.connectionIndex ||
308
+ (distance(p.via.center, q.via.center) >= d + c - EPS &&
309
+ p.segments.every(
310
+ (s) =>
311
+ distancePointToSegment(q.via.center, s.start, s.end) >=
312
+ d / 2 + w / 2 + c - EPS &&
313
+ q.segments.every((t) => segmentsAreClear(s, t, c)),
314
+ ) &&
315
+ q.segments.every(
316
+ (s) =>
317
+ distancePointToSegment(p.via.center, s.start, s.end) >=
318
+ d / 2 + w / 2 + c - EPS,
319
+ )),
320
+ )
321
+ }
322
+ const remaining = (fixed: PeripheralSourceEscape[]) => {
323
+ const ids = new Set(fixed.map((p) => p.connectionIndex))
324
+ return params.buses
325
+ .map((b) => ({
326
+ ...b,
327
+ connections: b.connections.filter((c) => !ids.has(c.connectionIndex)),
328
+ }))
329
+ .filter((b) => b.connections.length)
330
+ }
331
+ let preferred = params.initialViaPoints
332
+ const rules = (
333
+ fixed: PeripheralSourceEscape[],
334
+ ): DogboneViaSiteGeometryRules => ({
335
+ ...params,
336
+ additionalObstacles: srj.obstacles,
337
+ maximumSearchStates: 300_000,
338
+ blockingSegments: fixed.flatMap((p) =>
339
+ p.segments.map((segment) => ({
340
+ connectionIndex: p.connectionIndex,
341
+ segment,
342
+ })),
343
+ ),
344
+ blockingVias: fixed.map((p) => ({
345
+ connectionIndex: p.connectionIndex,
346
+ ...p.via,
347
+ })),
348
+ preferredViaPointsByConnectionIndex: preferred,
349
+ })
350
+ const rawFixed = selected.map((p) =>
351
+ make(byIndex.get(p.connectionIndex)!.connection, [
352
+ p.segments[0]!.start,
353
+ ...p.segments.map((s) => s.end),
354
+ ]),
355
+ )
356
+ const initial = matchComponentDogboneViaSites(
357
+ remaining(rawFixed),
358
+ rules(rawFixed),
359
+ )
360
+ if (!initial) return null
361
+ preferred = new Map([
362
+ ...initial,
363
+ ...rawFixed.map((p) => [p.connectionIndex, p.via.center] as const),
364
+ ])
365
+ // Unwrap the cyclic subset into the increasing order of this straight edge.
366
+ const byTarget = selected.toSorted(
367
+ (a, b) =>
368
+ target(byIndex.get(a.connectionIndex)!.connection).y -
369
+ target(byIndex.get(b.connectionIndex)!.connection).y,
370
+ )
371
+ let unwrapped: FanoutRoutePlan[] = []
372
+ for (const p of byTarget) {
373
+ const suffix = [p]
374
+ let last = p.exitPoint.x
375
+ for (const q of byTarget.slice(byTarget.indexOf(p) + 1))
376
+ if (q.exitPoint.x > last + pitch - EPS) {
377
+ suffix.push(q)
378
+ last = q.exitPoint.x
379
+ }
380
+ if (suffix.length > unwrapped.length) unwrapped = suffix
381
+ }
382
+ if (unwrapped.length < 3) return null
383
+ const sourcePoints = new Map(
384
+ unwrapped.map((p) => [
385
+ p.connectionIndex,
386
+ [p.segments[0]!.start, ...p.segments.map((s) => s.end)],
387
+ ]),
388
+ )
389
+ const viaX = bus.sharedBoundary.minX + Math.max(padPitch / 2, d / 2 + c),
390
+ roof = sourceBoundary.maxY + padPitch - pitch / 2,
391
+ side = sourceBoundary.minX - padPitch + pitch / 2
392
+ const extend = () =>
393
+ [...sourcePoints]
394
+ .sort(
395
+ ([a], [b]) =>
396
+ target(byIndex.get(a)!.connection).y -
397
+ target(byIndex.get(b)!.connection).y,
398
+ )
399
+ .map(([id, ps], i) => {
400
+ const end = target(byIndex.get(id)!.connection),
401
+ port = ps.at(-1)!
402
+ return make(
403
+ byIndex.get(id)!.connection,
404
+ chamferSplitPerimeter(
405
+ [
406
+ ...ps,
407
+ { x: port.x, y: roof + i * pitch },
408
+ { x: side - i * pitch, y: roof + i * pitch },
409
+ { x: side - i * pitch, y: end.y },
410
+ { x: viaX, y: end.y },
411
+ ],
412
+ w,
413
+ ),
414
+ )
415
+ })
416
+ let fixed = extend()
417
+ if (fixed.some((p) => !clear(p, fixed))) return null
418
+ // A local corner followed by a straight top escape can fill an unused ordered port.
419
+ for (const conn of bus.connections.toSorted(
420
+ (a, b) => target(a).y - target(b).y,
421
+ )) {
422
+ if (sourcePoints.has(conn.connectionIndex)) continue
423
+ const before =
424
+ [...sourcePoints]
425
+ .filter(
426
+ ([id]) => target(byIndex.get(id)!.connection).y < target(conn).y,
427
+ )
428
+ .at(-1)?.[1]
429
+ .at(-1)?.x ?? sourceBoundary.minX
430
+ const after =
431
+ [...sourcePoints]
432
+ .filter(
433
+ ([id]) => target(byIndex.get(id)!.connection).y > target(conn).y,
434
+ )[0]?.[1]
435
+ .at(-1)?.x ?? sourceBoundary.maxX
436
+ for (const sx of [-1, 1]) {
437
+ const point = {
438
+ x: conn.sourcePoint.x + (sx * padPitch) / 2,
439
+ y: conn.sourcePoint.y + padPitch / 2,
440
+ }
441
+ if (point.x < before + pitch - EPS || point.x > after - pitch + EPS)
442
+ continue
443
+ const ps = [
444
+ conn.sourcePoint,
445
+ point,
446
+ { x: point.x, y: sourceBoundary.maxY },
447
+ ]
448
+ sourcePoints.set(conn.connectionIndex, ps)
449
+ const attempt = extend()
450
+ if (
451
+ attempt.every((p) => clear(p, attempt)) &&
452
+ matchComponentDogboneViaSites(remaining(attempt), rules(attempt))
453
+ ) {
454
+ fixed = attempt
455
+ break
456
+ }
457
+ sourcePoints.delete(conn.connectionIndex)
458
+ }
459
+ }
460
+ const firstTarget = Math.min(
461
+ ...fixed.map((p) => target(byIndex.get(p.connectionIndex)!.connection).y),
462
+ )
463
+ for (const p of paths
464
+ .filter(
465
+ (p) =>
466
+ Math.abs(p.exitPoint.x - sourceBoundary.minX) < EPS &&
467
+ target(byIndex.get(p.connectionIndex)!.connection).y < firstTarget,
468
+ )
469
+ .sort(
470
+ (a, b) =>
471
+ target(byIndex.get(b.connectionIndex)!.connection).y -
472
+ target(byIndex.get(a.connectionIndex)!.connection).y,
473
+ )) {
474
+ const conn = byIndex.get(p.connectionIndex)!.connection,
475
+ end = target(conn),
476
+ column = sourceBoundary.minX - pitch,
477
+ attempt = make(
478
+ conn,
479
+ chamferSplitPerimeter(
480
+ [
481
+ p.segments[0]!.start,
482
+ ...p.segments.map((s) => s.end),
483
+ { x: column, y: p.exitPoint.y },
484
+ { x: column, y: end.y },
485
+ { x: viaX, y: end.y },
486
+ ],
487
+ w,
488
+ ),
489
+ )
490
+ if (
491
+ clear(attempt, fixed) &&
492
+ matchComponentDogboneViaSites(
493
+ remaining([...fixed, attempt]),
494
+ rules([...fixed, attempt]),
495
+ )
496
+ )
497
+ fixed.push(attempt)
498
+ }
499
+ const remoteConnectionIndices = new Set(fixed.map((p) => p.connectionIndex)),
500
+ locals = bus.connections
501
+ .filter((c) => !remoteConnectionIndices.has(c.connectionIndex))
502
+ .sort((a, b) => target(a).y - target(b).y)
503
+ // The lower triad is routed as an outer separator, a source-layer escape,
504
+ // and an inner lower lane. Other topologies retain the general fallback.
505
+ if (locals.length < 5) return null
506
+ const lower = locals.slice(0, 3),
507
+ bottomConnection = lower[1]!,
508
+ innerLower = lower[2]!
509
+ const obstacles = [
510
+ ...srj.obstacles,
511
+ ...fixed.flatMap((p) =>
512
+ p.segments.map((s) => ({
513
+ type: "rect" as const,
514
+ center: { x: (s.start.x + s.end.x) / 2, y: (s.start.y + s.end.y) / 2 },
515
+ width: distance(s.start, s.end),
516
+ height: s.width,
517
+ ccwRotationDegrees:
518
+ (Math.atan2(s.end.y - s.start.y, s.end.x - s.start.x) * 180) /
519
+ Math.PI,
520
+ layers: [s.layer],
521
+ connectedTo: [p.connectionName],
522
+ })),
523
+ ),
524
+ ]
525
+ const bottomPaths = yield* routeSingleLayerWithAdaptiveExitsSteps({
526
+ srj: { ...srj, obstacles },
527
+ buses: singleton([bottomConnection, innerLower]),
528
+ traceWidth: w,
529
+ clearance: c,
530
+ availableBoundaryRegions: [
531
+ { direction: "down", preferredExit: "bottom", exitEdge: "bottom" },
532
+ ],
533
+ })
534
+ if (!bottomPaths?.length) return null
535
+ const bp = bottomPaths.find(
536
+ (p) => p.connectionIndex === bottomConnection.connectionIndex,
537
+ )!,
538
+ bottom = make(bottomConnection, [
539
+ bp.segments[0]!.start,
540
+ ...bp.segments.map((s) => s.end),
541
+ ])
542
+ if (!clear(bottom, fixed)) return null
543
+ fixed.push(bottom)
544
+ remoteConnectionIndices.add(bottom.connectionIndex)
545
+ const rem = remaining(fixed),
546
+ fixedLower = new Map([
547
+ [
548
+ innerLower.connectionIndex,
549
+ {
550
+ x: innerLower.sourcePoint.x - padPitch / 2,
551
+ y: innerLower.sourcePoint.y - padPitch / 2,
552
+ },
553
+ ],
554
+ ])
555
+ const matched = matchComponentDogboneViaSites(rem, {
556
+ ...rules(fixed),
557
+ fixedViaPointsByConnectionIndex: fixedLower,
558
+ })
559
+ if (!matched) return null
560
+ const sourceEscapes = [
561
+ ...fixed,
562
+ ...rem.flatMap((b) =>
563
+ b.connections.map((conn) =>
564
+ make(conn, [conn.sourcePoint, matched.get(conn.connectionIndex)!]),
565
+ ),
566
+ ),
567
+ ]
568
+ if (sourceEscapes.some((p) => !clear(p, sourceEscapes))) return null
569
+ return {
570
+ sourceEscapes,
571
+ sourceBoundary,
572
+ remoteConnectionIndices,
573
+ bottomRemoteConnectionIndices: new Set([bottom.connectionIndex]),
574
+ lowerConnectionIndices: lower.map((c) => c.connectionIndex),
575
+ viaPointsByConnectionIndex: new Map(
576
+ sourceEscapes.map((p) => [p.connectionIndex, p.via.center]),
577
+ ),
578
+ }
579
+ }