@tscircuit/fanout-solver 0.0.60 → 0.0.62

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.
package/README.md CHANGED
@@ -282,8 +282,8 @@ extents.
282
282
 
283
283
  ## Dataset 31 benchmark
284
284
 
285
- Run `./benchmark.sh` (or `bun run benchmark`) to benchmark **only the 12 AM62L
286
- directional cases** from
285
+ Run `./benchmark.sh` (or `bun run benchmark`) to benchmark **all 24 dataset 31
286
+ directional cases: 12 AM62L and 12 RK3308** from
287
287
  [`tscircuit/dataset-fanout31-am62l`](https://github.com/tscircuit/dataset-fanout31-am62l).
288
288
  The upstream revision is pinned in `scripts/generate-repro/package.json` and
289
289
  recorded in every report. Other datasets remain available for regression tests
@@ -293,14 +293,24 @@ and the debugger, but have no benchmark commands or workflows.
293
293
  ./benchmark.sh
294
294
  ./benchmark.sh --list
295
295
  ./benchmark.sh --sample 11-left-center
296
+ ./benchmark.sh --sample 13-rk3308-top-left-offset
296
297
  ./benchmark.sh --concurrency 8 --sample-timeout-seconds 300
297
298
  ```
298
299
 
299
300
  Before timing the solver, the benchmark renders the selected upstream TSX/core
300
301
  circuits and captures their exact fanout-solver constructor inputs into
301
- `benchmark-results/inputs/<sample-id>.json`. Each case retains all 135 AM62L
302
- connections, 573 pad obstacles, nine DDR buses, 102 plane drops, and the original
303
- clearance, differential-pair, and length-skew constraints. The timed workers run
302
+ `benchmark-results/inputs/<sample-id>.json`. Each case retains its complete
303
+ SoC fanout workload and the original clearance, differential-pair, and length-skew
304
+ constraints:
305
+
306
+ | SoC | Cases | Signal connections | Plane drops | Total connections | Pad obstacles |
307
+ | --- | ---: | ---: | ---: | ---: | ---: |
308
+ | AM62L | 12 | 33 | 102 | 135 | 573 |
309
+ | RK3308 | 12 | 49 | 113 | 162 | 451 |
310
+
311
+ Both families have nine DDR signal buses. The RK3308 samples use a 355-ball SoC
312
+ and 96-ball DDR3L RAM, with the RAM placed on each side at three offsets.
313
+ The timed workers run
304
314
  **this checkout's solver**, not the upstream package's released solver.
305
315
  To capture the inputs without solving, use `bun run generate:dataset31`.
306
316
  The optional `--dataset dataset31` flag is accepted for explicit CI invocation;
@@ -335,8 +345,10 @@ are committed so route changes can be reviewed in Git. A run replaces the select
335
345
  cases' snapshots and removes their stale SVGs if they no longer solve; filtered
336
346
  runs preserve unselected snapshots. JSON reports and captured inputs remain
337
347
  ignored. CI includes the SVGs in its benchmark artifacts.
338
- Compare reports with the same budgets to track progress. Solved means validated
339
- AM62L fanout, not RAM fanout or downstream inter-chip routing. Partial, error,
348
+ Compare reports with the same dataset revision and budgets to track progress.
349
+ A case is solved only when every SoC connection has validated fanout: all 135
350
+ connections for AM62L or all 162 for RK3308. This covers the SoC fanout phase; RAM
351
+ fanout and downstream inter-chip routing are separate phases. Partial, error,
340
352
  and timeout rows are benchmark results (exit 0); invalid CLI arguments or report
341
353
  I/O failures are command failures (nonzero exit).
342
354
 
@@ -344,7 +356,7 @@ I/O failures are command failures (nonzero exit).
344
356
 
345
357
  Once `.github/workflows/benchmark.yml` is on the default branch, a repository
346
358
  writer can comment **`/benchmark`** on an open PR. The workflow captures that
347
- PR's exact head SHA, runs all 12 dataset 31 samples on a **32-vCPU Blacksmith ARM**
359
+ PR's exact head SHA, runs all 24 dataset 31 samples on a **32-vCPU Blacksmith ARM**
348
360
  runner, then updates a status comment with solve totals, per-sample results,
349
361
  and a link to the complete JSON/Markdown reports and captured inputs. The Actions
350
362
  UI also supports a manual run, optionally supplying an open PR number. No custom
@@ -510,8 +522,8 @@ bun run render:dataset
510
522
  bun run start
511
523
  ```
512
524
 
513
- The benchmark runs only the 12 dataset 31 AM62L cases and reports solve counts,
514
- validation, and timing. `bun run start` opens all regression
525
+ The benchmark runs all 24 dataset 31 AM62L and RK3308 cases and reports solve
526
+ counts, validation, and timing. `bun run start` opens all regression
515
527
  datasets in the standard tscircuit solver debugger. `bun run
516
528
  render:dataset` writes `graphics-debug` PNGs under one subdirectory per dataset,
517
529
  with a red shared boundary, gray component courtyards, and green fanout-exit
@@ -23,6 +23,7 @@ import {
23
23
  import { matchBusPlanLengths } from "./match-bus-lengths"
24
24
  import {
25
25
  getComponentDogboneViaSiteCandidates,
26
+ getSingleDogboneViaSiteRepairs,
26
27
  matchComponentDogboneViaSites,
27
28
  } from "./match-component-dogbone-via-sites"
28
29
  import { connectionsShareElectricalNet } from "./net-identity"
@@ -467,6 +468,9 @@ function createInitialLayerAssignment(params: {
467
468
  escapeLayers: string[]
468
469
  escapeLayersByBusId: Readonly<Record<string, readonly string[]>>
469
470
  preferOrderedCoordinatedWindingLayers: boolean
471
+ traceWidth: number
472
+ viaDiameter: number
473
+ clearance: number
470
474
  }): Readonly<Record<string, string>> {
471
475
  const {
472
476
  buses,
@@ -513,10 +517,151 @@ function createInitialLayerAssignment(params: {
513
517
  preferOrderedCoordinatedWindingLayers &&
514
518
  busUsesCoordinatedWinding(bus)
515
519
  ) {
516
- // Coordinated winding treats allowedLayers as an ordered preference.
517
- // A global round-robin index can otherwise skip a bus's first choice
518
- // just because a previous bus had a different set of legal layers.
519
- assignment[bus.busId] = viaLayers[0]!
520
+ // Preserve caller preferences when boundary corridors are free. A
521
+ // centered or turning bus with a common target layer can occupy the
522
+ // same boundary band, so prefer another legal layer for a wide route.
523
+ const cornerSide = getCornerBandSide(bus.exitEdge, bus.preferredExit)
524
+ const getBoundaryCongestion = (layer: string): number => {
525
+ if (!bus.exitEdge || bus.connections.length < 8) return 0
526
+ const horizontalEdge =
527
+ bus.exitEdge === "left" || bus.exitEdge === "right"
528
+ const axis = horizontalEdge ? "y" : "x"
529
+ const minimum = horizontalEdge
530
+ ? bus.sharedBoundary.minY
531
+ : bus.sharedBoundary.minX
532
+ const maximum = horizontalEdge
533
+ ? bus.sharedBoundary.maxY
534
+ : bus.sharedBoundary.maxX
535
+ if (!cornerSide) {
536
+ const tracks = bus.connections.map((connection) => {
537
+ const target =
538
+ connection.exitTargetPoint ?? connection.targetPoint
539
+ return Math.max(minimum, Math.min(maximum, target[axis]))
540
+ })
541
+ const bandMinimum = Math.min(...tracks)
542
+ const bandMaximum = Math.max(...tracks)
543
+ const forwardAxis = horizontalEdge ? "x" : "y"
544
+ const sign =
545
+ bus.exitEdge === "right" || bus.exitEdge === "top" ? 1 : -1
546
+ const sourceNearEnd = Math.min(
547
+ ...bus.connections.map(
548
+ (connection) => sign * connection.sourcePoint[forwardAxis],
549
+ ),
550
+ )
551
+ const sourceMinimum = Math.min(
552
+ ...bus.connections.map(
553
+ (connection) => connection.sourcePoint[axis],
554
+ ),
555
+ )
556
+ const sourceMaximum = Math.max(
557
+ ...bus.connections.map(
558
+ (connection) => connection.sourcePoint[axis],
559
+ ),
560
+ )
561
+ return buses.reduce((count, other) => {
562
+ const otherCorner = getCornerBandSide(
563
+ other.exitEdge,
564
+ other.preferredExit,
565
+ )
566
+ if (
567
+ other === bus ||
568
+ other.termination.type !== "boundary" ||
569
+ other.componentId !== bus.componentId ||
570
+ other.exitEdge !== bus.exitEdge ||
571
+ !otherCorner ||
572
+ getCommonExplicitExitTargetLayer(other) !== layer
573
+ )
574
+ return count
575
+ const center =
576
+ minimum +
577
+ (maximum - minimum) * (otherCorner === "minimum" ? 0.25 : 0.75)
578
+ const halfWidth =
579
+ ((Math.max(
580
+ other.connections.length,
581
+ other.cornerBandConnectionCount ?? 0,
582
+ ) -
583
+ 1) *
584
+ Math.max(
585
+ params.traceWidth + params.clearance,
586
+ params.viaDiameter + params.clearance,
587
+ )) /
588
+ 2
589
+ const margin = params.traceWidth + params.clearance
590
+ const overlapsBoundaryBand =
591
+ bandMaximum + margin >= center - halfWidth &&
592
+ bandMinimum - margin <= center + halfWidth
593
+ // A turning bus behind this source field must also pass its lanes
594
+ // on the way to the edge, even when its final band is elsewhere.
595
+ const crossesSourceField =
596
+ Math.max(
597
+ ...other.connections.map(
598
+ (connection) => sign * connection.sourcePoint[forwardAxis],
599
+ ),
600
+ ) <
601
+ sourceNearEnd - 1e-9 &&
602
+ Math.min(
603
+ ...other.connections.map(
604
+ (connection) => connection.sourcePoint[axis],
605
+ ),
606
+ ) <=
607
+ sourceMaximum + margin &&
608
+ Math.max(
609
+ ...other.connections.map(
610
+ (connection) => connection.sourcePoint[axis],
611
+ ),
612
+ ) >=
613
+ sourceMinimum - margin
614
+ return (
615
+ count +
616
+ (overlapsBoundaryBand || crossesSourceField
617
+ ? other.connections.length
618
+ : 0)
619
+ )
620
+ }, 0)
621
+ }
622
+ const bandCenter =
623
+ minimum +
624
+ (maximum - minimum) * (cornerSide === "minimum" ? 0.25 : 0.75)
625
+ const pitch = Math.max(
626
+ params.traceWidth + params.clearance,
627
+ params.viaDiameter + params.clearance,
628
+ )
629
+ const bandHalfWidth =
630
+ ((Math.max(
631
+ bus.connections.length,
632
+ bus.cornerBandConnectionCount ?? 0,
633
+ ) -
634
+ 1) *
635
+ pitch) /
636
+ 2
637
+ return buses.reduce((count, other) => {
638
+ if (
639
+ other === bus ||
640
+ other.termination.type !== "boundary" ||
641
+ other.componentId !== bus.componentId ||
642
+ other.exitEdge !== bus.exitEdge ||
643
+ getCornerBandSide(other.exitEdge, other.preferredExit) ||
644
+ getCommonExplicitExitTargetLayer(other) !== layer
645
+ )
646
+ return count
647
+ return (
648
+ count +
649
+ other.connections.filter((connection) => {
650
+ const target =
651
+ connection.exitTargetPoint ?? connection.targetPoint
652
+ const track = Math.max(minimum, Math.min(maximum, target[axis]))
653
+ return (
654
+ Math.abs(track - bandCenter) <=
655
+ bandHalfWidth + params.traceWidth + params.clearance
656
+ )
657
+ }).length
658
+ )
659
+ }, 0)
660
+ }
661
+ assignment[bus.busId] = viaLayers.toSorted(
662
+ (first, second) =>
663
+ getBoundaryCongestion(first) - getBoundaryCongestion(second),
664
+ )[0]!
520
665
  continue
521
666
  }
522
667
  const componentDirections = directionsByComponent.get(bus.componentId)!
@@ -1046,6 +1191,9 @@ export class FanoutSolver extends BaseSolver {
1046
1191
  buses: this.preparedBuses,
1047
1192
  escapeLayers: this.config.escapeLayers,
1048
1193
  escapeLayersByBusId: this.escapeLayersByBusId,
1194
+ traceWidth: this.config.traceWidth,
1195
+ viaDiameter: this.config.viaDiameter,
1196
+ clearance: this.config.clearance,
1049
1197
  preferOrderedCoordinatedWindingLayers:
1050
1198
  this.config.densePlaneReservationBusIds.length > 0 ||
1051
1199
  this.config.denseUnrestrictedPlaneRoutingBusIds.length > 0 ||
@@ -1486,6 +1634,64 @@ export class FanoutSolver extends BaseSolver {
1486
1634
  const wideBoundaryBuses = unsortedBoundaryBuses.filter(
1487
1635
  (bus) => bus.connections.length >= 8,
1488
1636
  )
1637
+ // A single-layer turning bus beside the end of a centered source field
1638
+ // has fewer escape choices than a corner bus farther behind it. Reserve
1639
+ // that turning channel before the farther bus fences its local via sites.
1640
+ const adjacentCenteredFieldByTurningBus = new Map<
1641
+ PreparedBus,
1642
+ PreparedBus
1643
+ >()
1644
+ if (usePadAlignedDenseRouting && !configuredDensePlaneRouting) {
1645
+ for (const bus of wideBoundaryBuses) {
1646
+ if (
1647
+ !getCornerBandSide(bus.exitEdge, bus.preferredExit) ||
1648
+ new Set(bus.routableEscapeLayers ?? bus.allowedLayers ?? []).size !==
1649
+ 1
1650
+ )
1651
+ continue
1652
+ const axis =
1653
+ bus.direction === "up" || bus.direction === "down" ? "y" : "x"
1654
+ const track = axis === "x" ? "y" : "x"
1655
+ const sign =
1656
+ bus.direction === "up" || bus.direction === "right" ? 1 : -1
1657
+ const forwardEnd = Math.max(
1658
+ ...bus.connections.map((c) => sign * c.sourcePoint[axis]),
1659
+ )
1660
+ const minTrack = Math.min(
1661
+ ...bus.connections.map((c) => c.sourcePoint[track]),
1662
+ )
1663
+ const maxTrack = Math.max(
1664
+ ...bus.connections.map((c) => c.sourcePoint[track]),
1665
+ )
1666
+ const pitch = axis === "x" ? bus.pitchX : bus.pitchY
1667
+ const field = wideBoundaryBuses.find((candidate) => {
1668
+ if (
1669
+ candidate === bus ||
1670
+ candidate.componentId !== bus.componentId ||
1671
+ candidate.exitEdge !== bus.exitEdge ||
1672
+ getCornerBandSide(candidate.exitEdge, candidate.preferredExit)
1673
+ )
1674
+ return false
1675
+ const nearEnd = Math.min(
1676
+ ...candidate.connections.map((c) => sign * c.sourcePoint[axis]),
1677
+ )
1678
+ const gap = nearEnd - forwardEnd
1679
+ return (
1680
+ gap >= -1e-9 &&
1681
+ gap <= pitch + 1e-9 &&
1682
+ Math.min(
1683
+ ...candidate.connections.map((c) => c.sourcePoint[track]),
1684
+ ) <=
1685
+ maxTrack + 1e-9 &&
1686
+ Math.max(
1687
+ ...candidate.connections.map((c) => c.sourcePoint[track]),
1688
+ ) >=
1689
+ minTrack - 1e-9
1690
+ )
1691
+ })
1692
+ if (field) adjacentCenteredFieldByTurningBus.set(bus, field)
1693
+ }
1694
+ }
1489
1695
  const hasThreeWideBoundaryBuses =
1490
1696
  useConfiguredDensePlaneRouting && wideBoundaryBuses.length === 3
1491
1697
  const getBoundaryTargetSpan = (bus: PreparedBus) => {
@@ -2189,13 +2395,44 @@ export class FanoutSolver extends BaseSolver {
2189
2395
  bus,
2190
2396
  ]),
2191
2397
  ]
2398
+ for (const [bus] of adjacentCenteredFieldByTurningBus) {
2399
+ const axis =
2400
+ bus.direction === "up" || bus.direction === "down" ? "y" : "x"
2401
+ const sign =
2402
+ bus.direction === "up" || bus.direction === "right" ? 1 : -1
2403
+ const backwardEnd = Math.min(
2404
+ ...bus.connections.map(
2405
+ (connection) => sign * connection.sourcePoint[axis],
2406
+ ),
2407
+ )
2408
+ const index = denseBoundaryBusesInRoutingOrder.indexOf(bus)
2409
+ const earlierCornerIndex = denseBoundaryBusesInRoutingOrder.findIndex(
2410
+ (candidate) =>
2411
+ candidate !== bus &&
2412
+ candidate.connections.length >= 8 &&
2413
+ candidate.componentId === bus.componentId &&
2414
+ candidate.direction === bus.direction &&
2415
+ Math.max(
2416
+ ...candidate.connections.map(
2417
+ (connection) => sign * connection.sourcePoint[axis],
2418
+ ),
2419
+ ) <=
2420
+ backwardEnd + 1e-9 &&
2421
+ candidate.exitEdge === bus.exitEdge &&
2422
+ getCornerBandSide(candidate.exitEdge, candidate.preferredExit) ===
2423
+ getCornerBandSide(bus.exitEdge, bus.preferredExit),
2424
+ )
2425
+ if (earlierCornerIndex >= 0 && earlierCornerIndex < index) {
2426
+ denseBoundaryBusesInRoutingOrder.splice(index, 1)
2427
+ denseBoundaryBusesInRoutingOrder.splice(earlierCornerIndex, 0, bus)
2428
+ }
2429
+ }
2192
2430
  const areAdjacentInvertedNarrowBuses = (
2193
2431
  first: PreparedBus,
2194
2432
  second: PreparedBus,
2195
2433
  ): boolean => {
2196
2434
  if (
2197
2435
  !usePadAlignedDenseRouting ||
2198
- useConfiguredDensePlaneRouting ||
2199
2436
  first.componentId !== second.componentId ||
2200
2437
  first.exitEdge !== second.exitEdge ||
2201
2438
  first.direction !== second.direction ||
@@ -2259,7 +2496,7 @@ export class FanoutSolver extends BaseSolver {
2259
2496
  // turning pair instead leaves its adjacent singleton room to escape
2260
2497
  // before searching for an outside-package via.
2261
2498
  const centeredPairPromotionGroups = new Set<string>()
2262
- for (const singleton of multiLayerLeadingSingletonBuses) {
2499
+ for (const singleton of leadingWideSingletonBuses) {
2263
2500
  if (getCornerBandSide(singleton.exitEdge, singleton.preferredExit))
2264
2501
  continue
2265
2502
  for (const pair of boundaryBuses) {
@@ -2300,6 +2537,25 @@ export class FanoutSolver extends BaseSolver {
2300
2537
  }
2301
2538
  }
2302
2539
  }
2540
+ for (const field of adjacentCenteredFieldByTurningBus.values()) {
2541
+ for (const pair of boundaryBuses) {
2542
+ if (
2543
+ pair.connections.length !== 2 ||
2544
+ getContainingWideSourceField(pair) !== field
2545
+ )
2546
+ continue
2547
+ for (const singleton of singletonBoundaryBuses) {
2548
+ if (!areAdjacentInvertedNarrowBuses(singleton, pair)) continue
2549
+ const pairIndex = denseBoundaryBusesInRoutingOrder.indexOf(pair)
2550
+ const singletonIndex =
2551
+ denseBoundaryBusesInRoutingOrder.indexOf(singleton)
2552
+ if (singletonIndex > pairIndex) {
2553
+ denseBoundaryBusesInRoutingOrder.splice(singletonIndex, 1)
2554
+ denseBoundaryBusesInRoutingOrder.splice(pairIndex, 0, singleton)
2555
+ }
2556
+ }
2557
+ }
2558
+ }
2303
2559
  let fixedViaPointsByConnectionIndex: ReadonlyMap<
2304
2560
  number,
2305
2561
  { x: number; y: number }
@@ -2447,7 +2703,7 @@ export class FanoutSolver extends BaseSolver {
2447
2703
  useConfiguredDensePlaneRouting &&
2448
2704
  singleLayerBus !== bus &&
2449
2705
  !embeddedNarrowBusAlreadyRouted
2450
- let usedSoftPlaneRepair = false
2706
+ let usedRepairedViaSites = false
2451
2707
  let busPlans = (yield* routeAlternatives(
2452
2708
  preferSingleLayerWinding
2453
2709
  ? { ...routeParams, bus: singleLayerBus }
@@ -2554,24 +2810,134 @@ export class FanoutSolver extends BaseSolver {
2554
2810
  }
2555
2811
  }
2556
2812
  }
2557
- // Once wide-bus copper constrains the field, retry a blocked wide bus
2558
- // with provisional plane sites as search costs. Future boundary sites
2559
- // remain fixed, and a complete joint rematch must validate the repair.
2813
+ // A first turning bus can be fenced by one diagonal site even though
2814
+ // every provisional dogbone is individually legal. Try moving one of
2815
+ // its own sites while retaining all other through-via reservations.
2816
+ // Keep this bounded repair ahead of the broader free-site search.
2560
2817
  if (
2561
2818
  !busPlans &&
2819
+ useAdaptiveDensePlaneRouting &&
2562
2820
  bus.connections.length >= 8 &&
2563
- boundaryBuses.some(
2564
- (candidate) =>
2565
- candidate.connections.length >= 8 &&
2566
- matchedPlans.some((plan) => plan.busId === candidate.busId),
2821
+ getCornerBandSide(bus.exitEdge, bus.preferredExit) &&
2822
+ new Set(bus.routableEscapeLayers ?? bus.allowedLayers ?? []).size ===
2823
+ 1 &&
2824
+ !matchedPlans.some((plan) =>
2825
+ wideBoundaryBuses.some(
2826
+ (candidate) => candidate.busId === plan.busId,
2827
+ ),
2567
2828
  )
2829
+ ) {
2830
+ const reservedVias = getReservedVias(bus)
2831
+ const siteRepairs = getSingleDogboneViaSiteRepairs(
2832
+ bus,
2833
+ {
2834
+ viaDiameter: this.config.viaDiameter,
2835
+ viaHoleDiameter: this.config.viaHoleDiameter,
2836
+ traceWidth: this.config.traceWidth,
2837
+ clearance: this.config.clearance,
2838
+ additionalObstacles: this.routingSrj.obstacles,
2839
+ blockingSegments: [
2840
+ ...matchedPlans.flatMap((plan) =>
2841
+ plan.segments.map((segment) => ({
2842
+ connectionIndex: plan.connectionIndex,
2843
+ segment,
2844
+ })),
2845
+ ),
2846
+ ...reservedVias.flatMap((reserved) =>
2847
+ reserved.sourceEscapeSegment
2848
+ ? [
2849
+ {
2850
+ connectionIndex: -1,
2851
+ segment: reserved.sourceEscapeSegment,
2852
+ },
2853
+ ]
2854
+ : [],
2855
+ ),
2856
+ ],
2857
+ blockingVias: [
2858
+ ...matchedPlans.flatMap((plan) =>
2859
+ plan.via
2860
+ ? [{ connectionIndex: plan.connectionIndex, ...plan.via }]
2861
+ : [],
2862
+ ),
2863
+ ...reservedVias.map((reserved) => ({
2864
+ connectionIndex: -1,
2865
+ ...reserved.via,
2866
+ })),
2867
+ ],
2868
+ },
2869
+ fixedViaPointsByConnectionIndex,
2870
+ )
2871
+ for (const replacementPoints of siteRepairs) {
2872
+ const replacementPlans = (yield* routeAlternatives(
2873
+ {
2874
+ ...routeParams,
2875
+ fixedViaPointsByConnectionIndex: replacementPoints,
2876
+ reservedVias,
2877
+ fixedViaFallbackRouteOrderAttempts: 1,
2878
+ },
2879
+ 1,
2880
+ ))[0]
2881
+ if (!replacementPlans) continue
2882
+ busPlans = replacementPlans
2883
+ fixedViaPointsByConnectionIndex = replacementPoints
2884
+ usedRepairedViaSites = true
2885
+ break
2886
+ }
2887
+ }
2888
+ // Fixed dogbones can close a turning bus's own escape channel. Retry
2889
+ // local sites while retaining every other connection's reservations.
2890
+ if (
2891
+ !busPlans &&
2892
+ useAdaptiveDensePlaneRouting &&
2893
+ bus.connections.length >= 8 &&
2894
+ getCornerBandSide(bus.exitEdge, bus.preferredExit)
2895
+ ) {
2896
+ busPlans = (yield* routeAlternatives(
2897
+ {
2898
+ ...routeParams,
2899
+ fixedViaPointsByConnectionIndex: undefined,
2900
+ reservedVias: getReservedVias(bus),
2901
+ },
2902
+ 1,
2903
+ ))[0]
2904
+ if (busPlans) {
2905
+ usedRepairedViaSites = true
2906
+ fixedViaPointsByConnectionIndex = new Map([
2907
+ ...fixedViaPointsByConnectionIndex,
2908
+ ...busPlans
2909
+ .filter((plan) => plan.via)
2910
+ .map(
2911
+ (plan) => [plan.connectionIndex, plan.via!.center] as const,
2912
+ ),
2913
+ ])
2914
+ }
2915
+ debugDense("local-sites", bus.busId, busPlans?.length ?? "failed")
2916
+ }
2917
+ // Retry a blocked wide bus with provisional plane sites as search
2918
+ // costs. A turning bus beside a centered field may also need that
2919
+ // neighboring field's uncommitted sites to move. Keep every committed
2920
+ // route and narrow reservation hard, then require a complete rematch.
2921
+ if (
2922
+ !busPlans &&
2923
+ bus.connections.length >= 8 &&
2924
+ (adjacentCenteredFieldByTurningBus.has(bus) ||
2925
+ boundaryBuses.some(
2926
+ (candidate) =>
2927
+ candidate.connections.length >= 8 &&
2928
+ matchedPlans.some((plan) => plan.busId === candidate.busId),
2929
+ ))
2568
2930
  ) {
2569
2931
  const committedNames = new Set(
2570
2932
  matchedPlans.map((plan) => plan.connectionName),
2571
2933
  )
2572
- const futurePlaneNames = new Set(
2934
+ const provisionalReservationNames = new Set(
2573
2935
  this.preparedBuses
2574
- .filter((candidate) => candidate.termination.type === "plane")
2936
+ .filter(
2937
+ (candidate) =>
2938
+ candidate.termination.type === "plane" ||
2939
+ candidate === adjacentCenteredFieldByTurningBus.get(bus),
2940
+ )
2575
2941
  .flatMap((candidate) =>
2576
2942
  candidate.connections.map(
2577
2943
  (connection) => connection.connection.name,
@@ -2584,10 +2950,11 @@ export class FanoutSolver extends BaseSolver {
2584
2950
  ...routeParams,
2585
2951
  fixedViaPointsByConnectionIndex: undefined,
2586
2952
  reservedVias: routeParams.reservedVias.filter(
2587
- (reserved) => !futurePlaneNames.has(reserved.connectionName),
2953
+ (reserved) =>
2954
+ !provisionalReservationNames.has(reserved.connectionName),
2588
2955
  ),
2589
2956
  softReservedVias: routeParams.reservedVias.filter((reserved) =>
2590
- futurePlaneNames.has(reserved.connectionName),
2957
+ provisionalReservationNames.has(reserved.connectionName),
2591
2958
  ),
2592
2959
  },
2593
2960
  1,
@@ -2630,7 +2997,7 @@ export class FanoutSolver extends BaseSolver {
2630
2997
  if (rematchedPoints) {
2631
2998
  busPlans = freePlans
2632
2999
  fixedViaPointsByConnectionIndex = rematchedPoints
2633
- usedSoftPlaneRepair = true
3000
+ usedRepairedViaSites = true
2634
3001
  }
2635
3002
  }
2636
3003
  }
@@ -2647,12 +3014,12 @@ export class FanoutSolver extends BaseSolver {
2647
3014
  // Only pay for additional A* variants when the first topology is so
2648
3015
  // skewed that compact meanders are unlikely to absorb the deficit.
2649
3016
  // This keeps already-near-matched buses on the single-attempt path.
2650
- // Keep the jointly rematched repair: routeParams still carries the
3017
+ // Keep repaired via sites: routeParams still carries the
2651
3018
  // earlier provisional sites and cannot safely replace its geometry.
2652
3019
  if (
2653
3020
  needsRouteDiversity &&
2654
3021
  !matchLengthsAfterPlanes &&
2655
- !usedSoftPlaneRepair
3022
+ !usedRepairedViaSites
2656
3023
  ) {
2657
3024
  busPlans = (yield* routeAlternatives(routeParams, 3)).toSorted(
2658
3025
  (first, second) => {
@@ -338,9 +338,33 @@ function replacementCopperIsSelfClear(params: {
338
338
  ) {
339
339
  continue
340
340
  }
341
+ const viaClearance = via.diameter / 2 + replacement.width / 2 + clearance
342
+ // An off-grid via can join this run through a short, connected stub.
343
+ // Preserve that existing connection while rejecting later approaches.
344
+ const connectsThroughShortStub = (direction: -1 | 1): boolean => {
345
+ let point = direction === -1 ? replacement.start : replacement.end
346
+ let pathDistance = 0
347
+ for (
348
+ let index = replacementIndex + direction;
349
+ index >= 0 && index < segments.length;
350
+ index += direction
351
+ ) {
352
+ const segment = segments[index]!
353
+ if (segment.layer !== replacement.layer) return false
354
+ const connectedEnd = direction === -1 ? segment.end : segment.start
355
+ if (!pointsMatch(point, connectedEnd)) return false
356
+ pathDistance += distance(segment.start, segment.end)
357
+ if (pathDistance > viaClearance + EPSILON) return false
358
+ point = direction === -1 ? segment.start : segment.end
359
+ if (pointsMatch(point, via.center)) return true
360
+ }
361
+ return false
362
+ }
341
363
  if (
342
364
  distancePointToSegment(via.center, replacement.start, replacement.end) <
343
- via.diameter / 2 + replacement.width / 2 + clearance - EPSILON
365
+ viaClearance - EPSILON &&
366
+ !connectsThroughShortStub(-1) &&
367
+ !connectsThroughShortStub(1)
344
368
  ) {
345
369
  return false
346
370
  }
@@ -782,3 +782,30 @@ export function getComponentDogboneViaSiteCandidates(
782
782
  ),
783
783
  )
784
784
  }
785
+
786
+ /** Try one adjacent site change without releasing any other fixed via. */
787
+ export function* getSingleDogboneViaSiteRepairs(
788
+ bus: PreparedBus,
789
+ rules: DogboneViaSiteGeometryRules,
790
+ fixedViaPointsByConnectionIndex: ReadonlyMap<number, Point2D>,
791
+ ): Generator<ReadonlyMap<number, Point2D>, void, unknown> {
792
+ const sites = getComponentDogboneViaSiteCandidates([bus], rules)
793
+ let remainingAttempts = 24
794
+ for (const connection of bus.connections.toReversed()) {
795
+ const original = fixedViaPointsByConnectionIndex.get(
796
+ connection.connectionIndex,
797
+ )
798
+ if (!original) continue
799
+ for (const candidate of sites) {
800
+ if (
801
+ candidate.connectionIndex !== connection.connectionIndex ||
802
+ distance(candidate.point, original) <= EPSILON
803
+ )
804
+ continue
805
+ if (remainingAttempts-- <= 0) return
806
+ const replacement = new Map(fixedViaPointsByConnectionIndex)
807
+ replacement.set(connection.connectionIndex, candidate.point)
808
+ yield replacement
809
+ }
810
+ }
811
+ }
package/lib/route-bus.ts CHANGED
@@ -165,6 +165,7 @@ function getWindingTargetOrders(params: {
165
165
  }): {
166
166
  orders: PreparedConnection[][]
167
167
  legacyOrder: PreparedConnection[]
168
+ ordinaryOrderCount: number
168
169
  } {
169
170
  const { bus, boundaryDirection, layerNames, targetLayer } = params
170
171
  const getTargetLayer = (candidate: PreparedConnection): string =>
@@ -268,6 +269,41 @@ function getWindingTargetOrders(params: {
268
269
  // but do not let sub-nanometer noise between unrelated layer bands choose
269
270
  // the primary topology.
270
271
  candidateOrders.push(legacyOrderedConnections)
272
+ const ordinaryOrderCount = new Set(
273
+ candidateOrders.map((order) =>
274
+ order.map((candidate) => candidate.connectionIndex).join(","),
275
+ ),
276
+ ).size
277
+ // Preserve each original layer's lane order while exploring other legal
278
+ // interleavings. Keep this bounded for buses with many source layers.
279
+ if (
280
+ !getCornerSide(bus) &&
281
+ bus.connections.length <= 8 &&
282
+ orderedLayers.length > 1
283
+ ) {
284
+ const layerSequences = orderedLayers.map((layer) =>
285
+ connectionsByLayer.get(layer)!.toSorted(compareWithinLayer),
286
+ )
287
+ const offsets = layerSequences.map(() => 0)
288
+ const current: PreparedConnection[] = []
289
+ const append = (): void => {
290
+ if (candidateOrders.length >= 128) return
291
+ if (current.length === bus.connections.length) {
292
+ candidateOrders.push([...current])
293
+ return
294
+ }
295
+ for (let layer = 0; layer < layerSequences.length; layer++) {
296
+ const next = layerSequences[layer]![offsets[layer]!]
297
+ if (!next) continue
298
+ offsets[layer]++
299
+ current.push(next)
300
+ append()
301
+ current.pop()
302
+ offsets[layer]--
303
+ }
304
+ }
305
+ append()
306
+ }
271
307
  const seenOrders = new Set<string>()
272
308
  const orders = candidateOrders.filter((order) => {
273
309
  const key = order.map((candidate) => candidate.connectionIndex).join(",")
@@ -275,7 +311,7 @@ function getWindingTargetOrders(params: {
275
311
  seenOrders.add(key)
276
312
  return true
277
313
  })
278
- return { orders, legacyOrder: legacyOrderedConnections }
314
+ return { orders, legacyOrder: legacyOrderedConnections, ordinaryOrderCount }
279
315
  }
280
316
 
281
317
  function getWindingTargetRank(params: {
@@ -318,6 +354,7 @@ function getDistributedBoundaryTargetTracks(params: {
318
354
  boundaryDirection: FanoutDirection
319
355
  traceWidth: number
320
356
  clearance: number
357
+ allowLayerInterleaving?: boolean
321
358
  }): number[] | undefined {
322
359
  const { bus, boundaryDirection, traceWidth, clearance } = params
323
360
  if (getCornerSide(bus) || !busUsesCoordinatedWindingChannel(bus))
@@ -352,6 +389,7 @@ function getDistributedBoundaryTargetTracks(params: {
352
389
  .toSorted((a, b) => a - b)
353
390
  const pitch = traceWidth + clearance
354
391
  if (
392
+ !params.allowLayerInterleaving &&
355
393
  tracks.every(
356
394
  (track, index) =>
357
395
  index === 0 || track - tracks[index - 1]! >= pitch - 1e-9,
@@ -396,6 +434,7 @@ export function getBoundaryTargetTrack(params: {
396
434
  layerNames?: readonly string[]
397
435
  targetLayer?: string
398
436
  windingOrderIndex?: number
437
+ allowLayerInterleaving?: boolean
399
438
  }): number {
400
439
  const requestedTrack = getPerpendicularAxis(
401
440
  params.connection.exitTargetPoint ?? params.connection.targetPoint,
@@ -2708,23 +2747,30 @@ export function* routeBusAlternativesSteps(
2708
2747
  windingOrderIndex?: number
2709
2748
  preferTargetDirectedLaneBias?: boolean
2710
2749
  localDogboneRepair?: boolean
2750
+ reserveTerminalExitPoints?: boolean
2751
+ cornerExitLaneOffset?: number
2752
+ allowLayerInterleaving?: boolean
2711
2753
  }
2712
2754
  const maximumThroughAllRouteOrderAttempts = 24
2713
- const windingTargetOrderCount =
2755
+ const usesDistributedWindingTargets = Boolean(
2714
2756
  cornerSide ||
2715
- getDistributedBoundaryTargetTracks({
2716
- bus,
2717
- boundaryDirection,
2718
- traceWidth,
2719
- clearance,
2720
- })
2721
- ? getWindingTargetOrders({
2722
- bus,
2723
- boundaryDirection,
2724
- layerNames,
2725
- targetLayer,
2726
- }).orders.length
2727
- : 1
2757
+ getDistributedBoundaryTargetTracks({
2758
+ bus,
2759
+ boundaryDirection,
2760
+ traceWidth,
2761
+ clearance,
2762
+ }),
2763
+ )
2764
+ const windingTargetOrders = getWindingTargetOrders({
2765
+ bus,
2766
+ boundaryDirection,
2767
+ layerNames,
2768
+ targetLayer,
2769
+ })
2770
+ const windingTargetOrderCount = windingTargetOrders.orders.length
2771
+ const ordinaryWindingTargetOrderCount = usesDistributedWindingTargets
2772
+ ? windingTargetOrders.ordinaryOrderCount
2773
+ : 1
2728
2774
  const uniformDogboneTerminalPatterns: CoordinatedTerminalPattern[] =
2729
2775
  viaHandednesses.map((viaHandedness) => ({
2730
2776
  label: `uniform-${viaHandedness}`,
@@ -2904,7 +2950,7 @@ export function* routeBusAlternativesSteps(
2904
2950
  coordinatedViaPoints
2905
2951
  ? [
2906
2952
  ...Array.from(
2907
- { length: windingTargetOrderCount },
2953
+ { length: ordinaryWindingTargetOrderCount },
2908
2954
  (_, windingOrderIndex) => ({
2909
2955
  label: `component-matched-vias-winding-${windingOrderIndex}`,
2910
2956
  useViaInPad: false,
@@ -2928,6 +2974,26 @@ export function* routeBusAlternativesSteps(
2928
2974
  windingOrderIndex: 0,
2929
2975
  preferTargetDirectedLaneBias: true,
2930
2976
  },
2977
+ // Keep ordinary fixed-site attempts first. Forward retries reserve
2978
+ // future exits so an earlier lane cannot close their final gap.
2979
+ ...(!getCornerSide(bus) && windingTargetOrderCount > 1
2980
+ ? Array.from(
2981
+ { length: windingTargetOrderCount },
2982
+ (_, windingOrderIndex) =>
2983
+ [true, false].map((preferTargetDirectedLaneBias) => ({
2984
+ label: `expanded-winding-${windingOrderIndex}-${preferTargetDirectedLaneBias}`,
2985
+ useViaInPad: false,
2986
+ getViaHandedness: () => 0 as const,
2987
+ getViaPoint: (connection: PreparedConnection) =>
2988
+ coordinatedViaPoints.get(connection.connectionIndex)!,
2989
+ maximumRouteOrderAttempts: 1,
2990
+ windingOrderIndex,
2991
+ preferTargetDirectedLaneBias,
2992
+ reserveTerminalExitPoints: !preferTargetDirectedLaneBias,
2993
+ allowLayerInterleaving: true,
2994
+ })),
2995
+ ).flat()
2996
+ : []),
2931
2997
  ]
2932
2998
  : []
2933
2999
  const planeTerminationsAlreadyOccupyTheFanout = acceptedPlans.some(
@@ -2995,6 +3061,23 @@ export function* routeBusAlternativesSteps(
2995
3061
  }
2996
3062
  }
2997
3063
  }
3064
+ if (alignWindingGridToPads && cornerSide) {
3065
+ const layerLocalExitOffset = getCornerLaneOffsets(
3066
+ bus,
3067
+ acceptedPlans.filter((plan) => plan.targetLayer === targetLayer),
3068
+ ).exit
3069
+ if (layerLocalExitOffset !== cornerLaneOffsets.exit) {
3070
+ // Preserve successful shared-band routes first. If those patterns
3071
+ // fail, reuse the corner slots occupied only on other copper layers.
3072
+ terminalPatterns.push(
3073
+ ...terminalPatterns.map((pattern) => ({
3074
+ ...pattern,
3075
+ label: `${pattern.label}-layer-local-corner`,
3076
+ cornerExitLaneOffset: layerLocalExitOffset,
3077
+ })),
3078
+ )
3079
+ }
3080
+ }
2998
3081
  const seenTerminalSignatures = new Set<string>()
2999
3082
  for (const terminalPattern of terminalPatterns) {
3000
3083
  const terminals = bus.connections.map((preparedConnection) => {
@@ -3004,7 +3087,8 @@ export function* routeBusAlternativesSteps(
3004
3087
  ? getCornerTargetTrack({
3005
3088
  bus,
3006
3089
  connection: preparedConnection,
3007
- cornerExitLaneOffset: cornerLaneOffsets.exit,
3090
+ cornerExitLaneOffset:
3091
+ terminalPattern.cornerExitLaneOffset ?? cornerLaneOffsets.exit,
3008
3092
  traceWidth,
3009
3093
  viaDiameter,
3010
3094
  clearance,
@@ -3014,6 +3098,7 @@ export function* routeBusAlternativesSteps(
3014
3098
  cornerBandTargetTrackOffset,
3015
3099
  })
3016
3100
  : getBoundaryTargetTrack({
3101
+ allowLayerInterleaving: terminalPattern.allowLayerInterleaving,
3017
3102
  bus,
3018
3103
  connection: preparedConnection,
3019
3104
  boundaryDirection,
@@ -3101,7 +3186,7 @@ export function* routeBusAlternativesSteps(
3101
3186
  )
3102
3187
  .join(
3103
3188
  "|",
3104
- )}:${terminalPattern.maximumRouteOrderAttempts ?? "all"}:${gridStepDivisor}:${alignGridToPads}:${Boolean(terminalPattern.localDogboneRepair)}`
3189
+ )}:${terminalPattern.maximumRouteOrderAttempts ?? "all"}:${gridStepDivisor}:${alignGridToPads}:${Boolean(terminalPattern.localDogboneRepair)}:${Boolean(terminalPattern.preferTargetDirectedLaneBias)}:${Boolean(terminalPattern.reserveTerminalExitPoints)}`
3105
3190
  if (seenTerminalSignatures.has(terminalSignature)) continue
3106
3191
  seenTerminalSignatures.add(terminalSignature)
3107
3192
  const windingSteps = routeViaMinimalWindingAlternativesSteps(
@@ -3124,6 +3209,7 @@ export function* routeBusAlternativesSteps(
3124
3209
  includeReverseTargetRotation: terminalPattern.localDogboneRepair,
3125
3210
  reservedVias,
3126
3211
  softReservedVias,
3212
+ reserveTerminalExitPoints: terminalPattern.reserveTerminalExitPoints,
3127
3213
  gridStepDivisor,
3128
3214
  preferTargetDirectedLaneBias:
3129
3215
  terminalPattern.preferTargetDirectedLaneBias,
@@ -76,6 +76,8 @@ export interface RouteViaMinimalWindingParams {
76
76
  alignGridToPads?: boolean
77
77
  /** Defer the outermost reversed target while routing the inner terminals. */
78
78
  includeReverseTargetRotation?: boolean
79
+ /** Keep earlier lanes clear of every remaining terminal exit. */
80
+ reserveTerminalExitPoints?: boolean
79
81
  }
80
82
 
81
83
  export interface RouteViaMinimalWindingProgress {
@@ -646,6 +648,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
646
648
  adaptiveRouteOrder = false,
647
649
  alignGridToPads = false,
648
650
  includeReverseTargetRotation = false,
651
+ reserveTerminalExitPoints = false,
649
652
  } = params
650
653
  if (
651
654
  maximumRouteOrderAttempts !== undefined &&
@@ -906,6 +909,14 @@ export function* routeViaMinimalWindingAlternativesSteps(
906
909
  return false
907
910
  }
908
911
  }
912
+ for (const other of reserveTerminalExitPoints ? terminals : []) {
913
+ if (sharesNet(connectionName, other.connection.connection.name)) continue
914
+ if (
915
+ distancePointToSegment(other.exitPoint, segment.start, segment.end) <
916
+ traceWidth + clearance - EPSILON
917
+ )
918
+ return false
919
+ }
909
920
  const segmentMinX = Math.min(segment.start.x, segment.end.x)
910
921
  const segmentMaxX = Math.max(segment.start.x, segment.end.x)
911
922
  const segmentMinY = Math.min(segment.start.y, segment.end.y)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.60",
3
+ "version": "0.0.62",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",