@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.
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 **all 24 dataset 31
286
- directional cases: 12 AM62L and 12 RK3308** from
285
+ Run `./benchmark.sh` (or `bun run benchmark`) to benchmark **all 36 dataset 31
286
+ directional cases: 12 AM62L, 12 RK3308, and 12 K230** 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
@@ -294,6 +294,7 @@ and the debugger, but have no benchmark commands or workflows.
294
294
  ./benchmark.sh --list
295
295
  ./benchmark.sh --sample 11-left-center
296
296
  ./benchmark.sh --sample 13-rk3308-top-left-offset
297
+ ./benchmark.sh --sample 25-k230-top-left-offset
297
298
  ./benchmark.sh --concurrency 8 --sample-timeout-seconds 300
298
299
  ```
299
300
 
@@ -307,11 +308,16 @@ constraints:
307
308
  | --- | ---: | ---: | ---: | ---: | ---: |
308
309
  | AM62L | 12 | 33 | 102 | 135 | 573 |
309
310
  | 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
314
- **this checkout's solver**, not the upstream package's released solver.
311
+ | K230 | 12 | 65 | 106 | 171 | 790 |
312
+
313
+ AM62L and RK3308 have nine DDR signal buses each. The RK3308 samples use a
314
+ 355-ball SoC and 96-ball DDR3L RAM. The K230 samples use a 390-ball SoC and two
315
+ 200-ball x16 LPDDR4 RAM packages, with 17 signal buses and six differential pairs.
316
+ Each family places its RAM on all four sides at three offsets per side. K230
317
+ retains both physical RAM endpoints of its shared RESET net; the two RAM packages
318
+ have separate breakout regions. All 790 pads in each K230 case remain obstacles,
319
+ including unused signal and supply pads. The timed workers run **this checkout's
320
+ solver**, not the upstream package's released solver.
315
321
  To capture the inputs without solving, use `bun run generate:dataset31`.
316
322
  The optional `--dataset dataset31` flag is accepted for explicit CI invocation;
317
323
  other dataset selections are rejected.
@@ -343,20 +349,21 @@ Timed-out workers do not retain their in-flight routing counts.
343
349
  Every solved case also writes `benchmark-results/<sample-id>.svg`. These SVGs
344
350
  are committed so route changes can be reviewed in Git. A run replaces the selected
345
351
  cases' snapshots and removes their stale SVGs if they no longer solve; filtered
346
- runs preserve unselected snapshots. JSON reports and captured inputs remain
347
- ignored. CI includes the SVGs in its benchmark artifacts.
352
+ runs preserve unselected snapshots. The latest JSON and Markdown reports are
353
+ also committed; captured inputs remain ignored. CI includes the reports, inputs,
354
+ and SVGs in its benchmark artifacts.
348
355
  Compare reports with the same dataset revision and budgets to track progress.
349
356
  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,
352
- and timeout rows are benchmark results (exit 0); invalid CLI arguments or report
357
+ connections for AM62L, all 162 for RK3308, or all 171 for K230. This covers the SoC
358
+ fanout phase; RAM fanout and downstream inter-chip routing are separate phases.
359
+ Partial, error, and timeout rows are benchmark results (exit 0); invalid CLI arguments or report
353
360
  I/O failures are command failures (nonzero exit).
354
361
 
355
362
  ### PR comment trigger
356
363
 
357
364
  Once `.github/workflows/benchmark.yml` is on the default branch, a repository
358
365
  writer can comment **`/benchmark`** on an open PR. The workflow captures that
359
- PR's exact head SHA, runs all 24 dataset 31 samples on a **32-vCPU Blacksmith ARM**
366
+ PR's exact head SHA, runs all 36 dataset 31 samples on a **32-vCPU Blacksmith ARM**
360
367
  runner, then updates a status comment with solve totals, per-sample results,
361
368
  and a link to the complete JSON/Markdown reports and captured inputs. The Actions
362
369
  UI also supports a manual run, optionally supplying an open PR number. No custom
@@ -522,8 +529,8 @@ bun run render:dataset
522
529
  bun run start
523
530
  ```
524
531
 
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
532
+ The benchmark runs all 36 dataset 31 AM62L, RK3308, and K230 cases and reports
533
+ solve counts, validation, and timing. `bun run start` opens all regression
527
534
  datasets in the standard tscircuit solver debugger. `bun run
528
535
  render:dataset` writes `graphics-debug` PNGs under one subdirectory per dataset,
529
536
  with a red shared boundary, gray component courtyards, and green fanout-exit
@@ -39,6 +39,13 @@ import {
39
39
  routeBusAlternatives,
40
40
  routeBusAlternativesSteps,
41
41
  } from "./route-bus"
42
+ import { routePeripheralSourceEscapesSteps } from "./route-peripheral-source-escapes"
43
+ import { routeStagedPerimeterBusSteps } from "./route-staged-perimeter-bus"
44
+ import { routeReservedSourceBusesSteps } from "./route-reserved-source-buses"
45
+ import { repairPeripheralBusLengthsSteps } from "./repair-peripheral-bus-lengths"
46
+ import { routeSplitPerimeterSourceEscapesSteps } from "./route-split-perimeter-source-escapes"
47
+ import { routeSplitPerimeterBusSteps } from "./route-split-perimeter-bus"
48
+ import { routeShallowSplitPerimeterBusSteps } from "./route-shallow-split-perimeter-bus"
42
49
  import { routeSingleLayerWithAdaptiveExitsSteps } from "./route-single-layer-adaptive-exits"
43
50
  import { routeSingleLayerWithPushAndShove } from "./route-single-layer-push-shove"
44
51
  import { getRuntimeProcess } from "./runtime-process"
@@ -84,6 +91,8 @@ interface ResolvedFanoutConfig {
84
91
 
85
92
  interface EvaluatedAssignment extends AssignmentAttempt {
86
93
  blockingBusIds: string[]
94
+ /** The bounded peripheral strategy may finish after complete validation. */
95
+ stopAfterCompleteValidation?: true
87
96
  }
88
97
 
89
98
  interface GroupedBeamState {
@@ -94,6 +103,7 @@ interface GroupedBeamState {
94
103
  interface MixedTerminationState {
95
104
  plans: FanoutRoutePlan[]
96
105
  failedBusIds: string[]
106
+ stopAfterCompleteValidation?: true
97
107
  }
98
108
 
99
109
  type RoutingStrategy = "default" | "group-by-layer" | "deep-first"
@@ -513,6 +523,43 @@ function createInitialLayerAssignment(params: {
513
523
  ) {
514
524
  assignment[bus.busId] = sourceLayer
515
525
  } else if (viaLayers.length > 0) {
526
+ // A source strictly inside a wide field can be enclosed by its routes.
527
+ // Boundary sources still have an outward channel, so preserve the
528
+ // ordinary layer preference for those buses.
529
+ const sourceXs = bus.connections.map(
530
+ (connection) => connection.sourcePoint.x,
531
+ )
532
+ const sourceYs = bus.connections.map(
533
+ (connection) => connection.sourcePoint.y,
534
+ )
535
+ const embeddedSingletonCount = (layer: string) =>
536
+ buses.filter((other) => {
537
+ const point = other.connections[0]?.sourcePoint
538
+ return (
539
+ other.termination.type === "boundary" &&
540
+ point !== undefined &&
541
+ point.x > Math.min(...sourceXs) + 1e-9 &&
542
+ point.x < Math.max(...sourceXs) - 1e-9 &&
543
+ point.y > Math.min(...sourceYs) + 1e-9 &&
544
+ point.y < Math.max(...sourceYs) - 1e-9 &&
545
+ getCommonExplicitExitTargetLayer(other) === layer &&
546
+ isDenseSingletonEmbeddedInMultiLayerWideBus({
547
+ singletonBus: other,
548
+ singletonTargetLayer: layer,
549
+ wideBuses: [bus],
550
+ })
551
+ )
552
+ }).length
553
+ const isolatedLayers = viaLayers.toSorted(
554
+ (a, b) => embeddedSingletonCount(a) - embeddedSingletonCount(b),
555
+ )
556
+ if (
557
+ embeddedSingletonCount(isolatedLayers[0]!) <
558
+ embeddedSingletonCount(viaLayers[0]!)
559
+ ) {
560
+ assignment[bus.busId] = isolatedLayers[0]!
561
+ continue
562
+ }
516
563
  if (
517
564
  preferOrderedCoordinatedWindingLayers &&
518
565
  busUsesCoordinatedWinding(bus)
@@ -1535,6 +1582,127 @@ export class FanoutSolver extends BaseSolver {
1535
1582
  })
1536
1583
  }
1537
1584
 
1585
+ /** Reserve source escapes before joining complete buses around the package. */
1586
+ private *routePeripheralMixedTerminationSteps(params: {
1587
+ busLayerAssignments: Readonly<Record<string, string>>
1588
+ busesInRoutingOrder: readonly PreparedBus[]
1589
+ }): Generator<FanoutWorkYield, MixedTerminationState | null, unknown> {
1590
+ if (this.config.allowBlindAndBuriedVias) return null
1591
+ const bus = params.busesInRoutingOrder.find(
1592
+ (candidate) =>
1593
+ candidate.termination.type === "boundary" &&
1594
+ (candidate.exitEdge === "right" || candidate.exitEdge === "left") &&
1595
+ candidate.connections.length >= 16 &&
1596
+ candidate.componentObstacles.length >= 200,
1597
+ )
1598
+ if (!bus) return null
1599
+ const targetLayer = params.busLayerAssignments[bus.busId]
1600
+ if (!targetLayer) return null
1601
+ const targetLayerByBusId = new Map(
1602
+ Object.entries(params.busLayerAssignments),
1603
+ )
1604
+ this.setInProgressPlans({
1605
+ phase: "route-peripheral-source-escapes",
1606
+ plans: [],
1607
+ busId: bus.busId,
1608
+ })
1609
+ const sourceSteps = (
1610
+ bus.exitEdge === "left"
1611
+ ? routeSplitPerimeterSourceEscapesSteps
1612
+ : routePeripheralSourceEscapesSteps
1613
+ )({
1614
+ ...this.config,
1615
+ srj: this.routingSrj,
1616
+ buses: this.preparedBuses,
1617
+ bus,
1618
+ targetLayer,
1619
+ targetLayerByBusId,
1620
+ })
1621
+ let sourceResult = sourceSteps.next()
1622
+ while (!sourceResult.done) {
1623
+ yield
1624
+ sourceResult = sourceSteps.next()
1625
+ }
1626
+ if (!sourceResult.value) return null
1627
+ let source = sourceResult.value
1628
+ const stageParams = {
1629
+ ...this.config,
1630
+ ...source,
1631
+ srj: this.routingSrj,
1632
+ bus,
1633
+ targetLayer,
1634
+ }
1635
+ const stageSteps =
1636
+ "lowerConnectionIndices" in source
1637
+ ? routeSplitPerimeterBusSteps({ ...stageParams, ...source })
1638
+ : routeStagedPerimeterBusSteps(stageParams)
1639
+ let stageResult = stageSteps.next()
1640
+ while (!stageResult.done) {
1641
+ yield
1642
+ stageResult = stageSteps.next()
1643
+ }
1644
+ let stagePlans = stageResult.value
1645
+ if (!stagePlans && "lowerConnectionIndices" in source) {
1646
+ const shallowSteps = routeShallowSplitPerimeterBusSteps({
1647
+ ...stageParams,
1648
+ ...source,
1649
+ buses: this.preparedBuses,
1650
+ })
1651
+ let shallowResult = shallowSteps.next()
1652
+ while (!shallowResult.done) {
1653
+ yield
1654
+ shallowResult = shallowSteps.next()
1655
+ }
1656
+ if (shallowResult.value) {
1657
+ source = shallowResult.value
1658
+ stagePlans = shallowResult.value.plans
1659
+ }
1660
+ }
1661
+ if (!stagePlans) return null
1662
+ this.setInProgressPlans({
1663
+ phase: "route-peripheral-bus-continuations",
1664
+ plans: stagePlans,
1665
+ busId: bus.busId,
1666
+ })
1667
+ const remainingSteps = routeReservedSourceBusesSteps({
1668
+ ...this.config,
1669
+ srj: this.routingSrj,
1670
+ buses: this.preparedBuses,
1671
+ sourceEscapes: source.sourceEscapes,
1672
+ sourceBoundary: source.sourceBoundary,
1673
+ initialPlans: stagePlans,
1674
+ targetLayerByBusId,
1675
+ })
1676
+ let remainingResult = remainingSteps.next()
1677
+ while (!remainingResult.done) {
1678
+ yield
1679
+ remainingResult = remainingSteps.next()
1680
+ }
1681
+ if (!remainingResult.value) return null
1682
+ const repairSteps = repairPeripheralBusLengthsSteps({
1683
+ ...this.config,
1684
+ srj: this.routingSrj,
1685
+ inputSrj: this.inputSrj,
1686
+ plans: remainingResult.value,
1687
+ preparedBuses: this.preparedBuses,
1688
+ sharedBoundary: this.getValidationBoundary(),
1689
+ })
1690
+ let repairResult = repairSteps.next()
1691
+ while (!repairResult.done) {
1692
+ yield
1693
+ repairResult = repairSteps.next()
1694
+ }
1695
+ if (!repairResult.value) return null
1696
+ const plans = repairResult.value
1697
+ const output = buildOutputSimpleRouteJson({
1698
+ inputSrj: this.inputSrj,
1699
+ plans,
1700
+ layerNames: this.config.layerNames,
1701
+ })
1702
+ if (!this.validateCompletePlans(plans, output).valid) return null
1703
+ return { plans, failedBusIds: [], stopAfterCompleteValidation: true }
1704
+ }
1705
+
1538
1706
  /**
1539
1707
  * Through-all source vias from a wide boundary bus can consume the only
1540
1708
  * legal dogbone channel for nearby plane pads. Conversely, routing hundreds
@@ -5142,6 +5310,23 @@ export class FanoutSolver extends BaseSolver {
5142
5310
 
5143
5311
  let mixedTerminationState: MixedTerminationState | null = null
5144
5312
  if (!useSingleLayerPushAndShove && routingStrategy === "default") {
5313
+ const peripheralSolver = this.createWorkSolver(
5314
+ "PeripheralMixedTerminationSolver",
5315
+ this.routePeripheralMixedTerminationSteps({
5316
+ busLayerAssignments,
5317
+ busesInRoutingOrder,
5318
+ }),
5319
+ )
5320
+ mixedTerminationState = (yield {
5321
+ type: "subsolver",
5322
+ solver: peripheralSolver,
5323
+ }) as MixedTerminationState | null
5324
+ }
5325
+ if (
5326
+ !mixedTerminationState &&
5327
+ !useSingleLayerPushAndShove &&
5328
+ routingStrategy === "default"
5329
+ ) {
5145
5330
  const denseSolver = this.createWorkSolver(
5146
5331
  "DenseMixedTerminationSolver",
5147
5332
  this.routeDenseThroughAllMixedTerminationSteps({
@@ -5341,6 +5526,10 @@ export class FanoutSolver extends BaseSolver {
5341
5526
  return {
5342
5527
  summary,
5343
5528
  plans,
5529
+ ...(mixedTerminationState?.stopAfterCompleteValidation &&
5530
+ validation?.valid
5531
+ ? { stopAfterCompleteValidation: true as const }
5532
+ : {}),
5344
5533
  blockingBusIds: [...blockingBusCounts.entries()]
5345
5534
  .toSorted(([, firstCount], [, secondCount]) => secondCount - firstCount)
5346
5535
  .map(([busId]) => busId),
@@ -5361,7 +5550,8 @@ export class FanoutSolver extends BaseSolver {
5361
5550
  if (
5362
5551
  bestAttempt.summary.routedConnectionCount ===
5363
5552
  this.inputSrj.connections.length &&
5364
- this.getCoordinatedAdditionalViaCount(bestAttempt.plans) === 0
5553
+ (bestAttempt.stopAfterCompleteValidation ||
5554
+ this.getCoordinatedAdditionalViaCount(bestAttempt.plans) === 0)
5365
5555
  ) {
5366
5556
  return bestAttempt
5367
5557
  }
@@ -5378,7 +5568,8 @@ export class FanoutSolver extends BaseSolver {
5378
5568
  if (
5379
5569
  bestAttempt.summary.routedConnectionCount ===
5380
5570
  this.inputSrj.connections.length &&
5381
- this.getCoordinatedAdditionalViaCount(bestAttempt.plans) === 0
5571
+ (bestAttempt.stopAfterCompleteValidation ||
5572
+ this.getCoordinatedAdditionalViaCount(bestAttempt.plans) === 0)
5382
5573
  ) {
5383
5574
  return bestAttempt
5384
5575
  }
@@ -5932,10 +6123,11 @@ export class FanoutSolver extends BaseSolver {
5932
6123
  bestScore: this.bestAttempt.summary.score,
5933
6124
  }
5934
6125
  if (
5935
- this.groupedBeamEvaluated &&
5936
- attempt.summary.routedConnectionCount ===
5937
- this.inputSrj.connections.length &&
5938
- this.getCoordinatedAdditionalViaCount(this.bestAttempt.plans) === 0
6126
+ (attempt.stopAfterCompleteValidation && this.hasCompleteBestAttempt()) ||
6127
+ (this.groupedBeamEvaluated &&
6128
+ attempt.summary.routedConnectionCount ===
6129
+ this.inputSrj.connections.length &&
6130
+ this.getCoordinatedAdditionalViaCount(this.bestAttempt.plans) === 0)
5939
6131
  ) {
5940
6132
  this.completeBestAttemptEndpoints()
5941
6133
  this.solved = true
@@ -0,0 +1,102 @@
1
+ import { getCornerBandSide } from "./boundary-exit"
2
+ import type { RouteBusParams } from "./route-bus"
3
+
4
+ /** Midpoints of unoccupied edge intervals, clipped to the bus's declared band. */
5
+ export function getFreeBoundaryTracks(
6
+ params: Pick<
7
+ RouteBusParams,
8
+ | "bus"
9
+ | "targetLayer"
10
+ | "acceptedPlans"
11
+ | "reservedVias"
12
+ | "traceWidth"
13
+ | "clearance"
14
+ >,
15
+ ): number[] {
16
+ const { bus, targetLayer, traceWidth, clearance } = params
17
+ if (!bus.exitEdge) return []
18
+ const vertical = bus.exitEdge === "left" || bus.exitEdge === "right"
19
+ const along = vertical ? "y" : "x"
20
+ const across = vertical ? "x" : "y"
21
+ const boundary = bus.sharedBoundary
22
+ const edge =
23
+ bus.exitEdge === "left"
24
+ ? boundary.minX
25
+ : bus.exitEdge === "right"
26
+ ? boundary.maxX
27
+ : bus.exitEdge === "bottom"
28
+ ? boundary.minY
29
+ : boundary.maxY
30
+ const lower = vertical ? boundary.minY : boundary.minX
31
+ const upper = vertical ? boundary.maxY : boundary.maxX
32
+ const middle = (lower + upper) / 2
33
+ const side = getCornerBandSide(bus.exitEdge, bus.preferredExit)
34
+ const minimum = (side === "maximum" ? middle : lower) + traceWidth / 2
35
+ const maximum = (side === "minimum" ? middle : upper) - traceWidth / 2
36
+ const blocked: [number, number][] = []
37
+ for (const plan of params.acceptedPlans) {
38
+ for (const segment of [
39
+ ...plan.segments,
40
+ ...(plan.planeEndpointSegments ?? []),
41
+ ]) {
42
+ if (segment.layer !== targetLayer) continue
43
+ const radius = (segment.width + traceWidth) / 2 + clearance
44
+ const start = segment.start[across]
45
+ const delta = segment.end[across] - start
46
+ let from = 0
47
+ let to = 1
48
+ if (Math.abs(delta) < 1e-9) {
49
+ if (Math.abs(start - edge) > radius) continue
50
+ } else {
51
+ const first = (edge - radius - start) / delta
52
+ const last = (edge + radius - start) / delta
53
+ from = Math.max(0, Math.min(first, last))
54
+ to = Math.min(1, Math.max(first, last))
55
+ if (from > to) continue
56
+ }
57
+ const alongStart = segment.start[along]
58
+ const alongDelta = segment.end[along] - alongStart
59
+ const first = alongStart + from * alongDelta
60
+ const last = alongStart + to * alongDelta
61
+ blocked.push([
62
+ Math.min(first, last) - radius,
63
+ Math.max(first, last) + radius,
64
+ ])
65
+ }
66
+ }
67
+ const vias = [
68
+ ...params.acceptedPlans.flatMap((plan) => [
69
+ ...(plan.via ? [plan.via] : []),
70
+ ...(plan.additionalVias ?? []),
71
+ ...(plan.planeEndpointVia ? [plan.planeEndpointVia] : []),
72
+ ]),
73
+ ...(params.reservedVias ?? []).map((reserved) => reserved.via),
74
+ ]
75
+ for (const via of vias) {
76
+ if (!via.spanLayers.includes(targetLayer)) continue
77
+ const radius = via.diameter / 2 + traceWidth / 2 + clearance
78
+ const distance = Math.abs(via.center[across] - edge)
79
+ if (distance >= radius) continue
80
+ const extent = Math.sqrt(radius * radius - distance * distance)
81
+ blocked.push([via.center[along] - extent, via.center[along] + extent])
82
+ }
83
+ const gaps: [number, number][] = []
84
+ let cursor = minimum
85
+ for (const [from, to] of blocked.sort((a, b) => a[0] - b[0])) {
86
+ if (to <= cursor || from >= maximum) continue
87
+ if (from > cursor) gaps.push([cursor, Math.min(from, maximum)])
88
+ cursor = Math.max(cursor, to)
89
+ if (cursor >= maximum) break
90
+ }
91
+ if (cursor < maximum) gaps.push([cursor, maximum])
92
+ // Edge order gives deterministic, symmetric coverage without hard-coded tracks.
93
+ const tracks = gaps
94
+ .filter(([from, to]) => to - from > 1e-6)
95
+ .map(([from, to]) => (from + to) / 2)
96
+ return tracks.length <= 32
97
+ ? tracks
98
+ : Array.from(
99
+ { length: 32 },
100
+ (_, index) => tracks[Math.round((index * (tracks.length - 1)) / 31)]!,
101
+ )
102
+ }
@@ -0,0 +1,239 @@
1
+ import { distance, distancePointToSegment, segmentsAreClear } from "./geometry"
2
+ import {
3
+ getComponentDogboneViaSiteCandidates,
4
+ matchComponentDogboneViaSites,
5
+ type DogboneViaSiteGeometryRules,
6
+ } from "./match-component-dogbone-via-sites"
7
+ import type {
8
+ Point2D,
9
+ PreparedBus,
10
+ PreparedConnection,
11
+ RoutedSegment,
12
+ } from "./types"
13
+
14
+ const EPSILON = 1e-9
15
+ const TAU = Math.PI * 2
16
+ interface Candidate {
17
+ connectionIndex: number
18
+ point: Point2D
19
+ angle: number
20
+ sourceSegment: RoutedSegment
21
+ siteIndex: number
22
+ }
23
+
24
+ /** Reserve ordered source vias, then assign every remaining source via together. */
25
+ export function matchAngularlyOrderedLocalVias(params: {
26
+ buses: readonly PreparedBus[]
27
+ busId: string
28
+ rules: DogboneViaSiteGeometryRules
29
+ maximumOrderingStates?: number
30
+ maximumCompleteAssignments?: number
31
+ }): Map<number, Point2D> | null {
32
+ const { buses, rules } = params
33
+ const bus = buses.find((b) => b.busId === params.busId)
34
+ if (
35
+ !bus ||
36
+ bus.termination.type !== "boundary" ||
37
+ !bus.exitEdge ||
38
+ bus.connections.length < 3
39
+ )
40
+ return null
41
+ const maximumOrderingStates = params.maximumOrderingStates ?? 10_000
42
+ const maximumCompleteAssignments = params.maximumCompleteAssignments ?? 16
43
+ for (const [name, value] of Object.entries({
44
+ maximumOrderingStates,
45
+ maximumCompleteAssignments,
46
+ })) {
47
+ if (!Number.isSafeInteger(value) || value < 1)
48
+ throw new Error(`FanoutSolver: ${name} must be a positive safe integer`)
49
+ }
50
+ const center = {
51
+ x: (bus.componentBounds.minX + bus.componentBounds.maxX) / 2,
52
+ y: (bus.componentBounds.minY + bus.componentBounds.maxY) / 2,
53
+ }
54
+ const angle = (p: Point2D) => Math.atan2(p.y - center.y, p.x - center.x)
55
+ const targetCoordinate = (c: PreparedConnection) => {
56
+ const p = c.exitTargetPoint ?? c.targetPoint
57
+ return bus.exitEdge === "right"
58
+ ? p.y
59
+ : bus.exitEdge === "top"
60
+ ? -p.x
61
+ : bus.exitEdge === "left"
62
+ ? -p.y
63
+ : p.x
64
+ }
65
+ const ordered = bus.connections.toSorted(
66
+ (a, b) =>
67
+ targetCoordinate(a) - targetCoordinate(b) ||
68
+ a.connectionIndex - b.connectionIndex,
69
+ )
70
+ const sourceAngles: number[] = []
71
+ for (const c of ordered) {
72
+ if (distance(c.sourcePoint, center) < EPSILON) return null
73
+ let a = angle(c.sourcePoint)
74
+ while (a < (sourceAngles.at(-1) ?? a) - EPSILON) a += TAU
75
+ sourceAngles.push(a)
76
+ }
77
+ if (sourceAngles.at(-1)! - sourceAngles[0]! >= TAU - EPSILON) return null
78
+ const connections = new Map(
79
+ buses.flatMap((b) =>
80
+ b.connections.map((c) => [c.connectionIndex, c] as const),
81
+ ),
82
+ )
83
+ const orderedIndex = new Map(ordered.map((c, i) => [c.connectionIndex, i]))
84
+ const sites = new Map<string, number>()
85
+ const candidateGroups = new Map<number, Candidate[]>()
86
+ for (const c of connections.values())
87
+ candidateGroups.set(c.connectionIndex, [])
88
+ for (const raw of getComponentDogboneViaSiteCandidates(buses, rules)) {
89
+ const c = connections.get(raw.connectionIndex)!
90
+ const key = `${raw.point.x.toFixed(9)},${raw.point.y.toFixed(9)}`
91
+ if (!sites.has(key)) sites.set(key, sites.size)
92
+ const index = orderedIndex.get(raw.connectionIndex)
93
+ const sourceAngle = angle(c.sourcePoint)
94
+ const a =
95
+ index === undefined
96
+ ? angle(raw.point)
97
+ : sourceAngles[index]! +
98
+ Math.atan2(
99
+ Math.sin(angle(raw.point) - sourceAngle),
100
+ Math.cos(angle(raw.point) - sourceAngle),
101
+ )
102
+ candidateGroups.get(raw.connectionIndex)!.push({
103
+ ...raw,
104
+ angle: a,
105
+ siteIndex: sites.get(key)!,
106
+ sourceSegment: {
107
+ start: c.sourcePoint,
108
+ end: raw.point,
109
+ layer: c.sourceLayer,
110
+ width: rules.traceWidth,
111
+ },
112
+ })
113
+ }
114
+ const groups = [...candidateGroups.entries()].map(
115
+ ([connectionIndex, candidates]) => ({ connectionIndex, candidates }),
116
+ )
117
+ if (groups.some((g) => g.candidates.length === 0)) return null
118
+ const domains = ordered.map((c, i) =>
119
+ candidateGroups
120
+ .get(c.connectionIndex)!
121
+ .toSorted(
122
+ (a, b) =>
123
+ Math.abs(a.angle - sourceAngles[i]!) -
124
+ Math.abs(b.angle - sourceAngles[i]!) ||
125
+ distance(b.point, center) - distance(a.point, center),
126
+ ),
127
+ )
128
+ const requiredHoleSeparation = rules.viaHoleDiameter
129
+ ? rules.viaHoleDiameter + (rules.holeToHoleClearance ?? rules.clearance)
130
+ : 0
131
+ const requiredViaToTraceSeparation =
132
+ rules.viaDiameter / 2 + rules.traceWidth / 2 + rules.clearance
133
+ const compatible = (a: Candidate, b: Candidate) => {
134
+ const share =
135
+ rules.canShareCopper?.(a.connectionIndex, b.connectionIndex) ?? false
136
+ const minimumViaDistance = share
137
+ ? requiredHoleSeparation
138
+ : Math.max(requiredHoleSeparation, rules.viaDiameter + rules.clearance)
139
+ if (distance(a.point, b.point) < minimumViaDistance - EPSILON) return false
140
+ if (share) return true
141
+ return (
142
+ distancePointToSegment(
143
+ a.point,
144
+ b.sourceSegment.start,
145
+ b.sourceSegment.end,
146
+ ) >=
147
+ requiredViaToTraceSeparation - EPSILON &&
148
+ distancePointToSegment(
149
+ b.point,
150
+ a.sourceSegment.start,
151
+ a.sourceSegment.end,
152
+ ) >=
153
+ requiredViaToTraceSeparation - EPSILON &&
154
+ segmentsAreClear(a.sourceSegment, b.sourceSegment, rules.clearance)
155
+ )
156
+ }
157
+ const forbidden = new Map<Candidate, Set<Candidate>>()
158
+ for (const domain of domains)
159
+ for (const a of domain) {
160
+ const conflicts = new Set<Candidate>()
161
+ for (const group of groups)
162
+ for (const b of group.candidates) {
163
+ if (a.connectionIndex !== b.connectionIndex && !compatible(a, b))
164
+ conflicts.add(b)
165
+ }
166
+ forbidden.set(a, conflicts)
167
+ }
168
+ const chosen: Candidate[] = []
169
+ // A perfect matching is necessary because distinct drilled holes cannot use
170
+ // the same site. It is a pruning test; native matching still validates copper.
171
+ const canUseDistinctSiteCheck =
172
+ requiredHoleSeparation > EPSILON || !rules.canShareCopper
173
+ const remainingSitesCanMatch = () => {
174
+ if (!canUseDistinctSiteCheck) return true
175
+ const selected = new Map(
176
+ chosen.map((c) => [c.connectionIndex, c.siteIndex]),
177
+ )
178
+ const allowed = groups
179
+ .map((g) =>
180
+ g.candidates
181
+ .filter(
182
+ (c) =>
183
+ (!selected.has(c.connectionIndex) ||
184
+ selected.get(c.connectionIndex) === c.siteIndex) &&
185
+ chosen.every((a) => !forbidden.get(a)!.has(c)),
186
+ )
187
+ .map((c) => c.siteIndex),
188
+ )
189
+ .sort((a, b) => a.length - b.length)
190
+ if (allowed.some((d) => d.length === 0)) return false
191
+ const owner = new Int32Array(sites.size).fill(-1)
192
+ const augment = (index: number, visited: Uint8Array): boolean => {
193
+ for (const site of allowed[index]!) {
194
+ if (visited[site]) continue
195
+ visited[site] = 1
196
+ if (owner[site] === -1 || augment(owner[site]!, visited)) {
197
+ owner[site] = index
198
+ return true
199
+ }
200
+ }
201
+ return false
202
+ }
203
+ return allowed.every((_, i) => augment(i, new Uint8Array(sites.size)))
204
+ }
205
+ let states = 0
206
+ let completeAssignments = 0
207
+ let result: Map<number, Point2D> | null = null
208
+ const search = (index: number): boolean => {
209
+ if (++states > maximumOrderingStates) return false
210
+ if (index === domains.length) {
211
+ if (++completeAssignments > maximumCompleteAssignments) return false
212
+ const fixed = new Map(rules.fixedViaPointsByConnectionIndex)
213
+ for (const c of chosen) fixed.set(c.connectionIndex, c.point)
214
+ result = matchComponentDogboneViaSites(buses, {
215
+ ...rules,
216
+ fixedViaPointsByConnectionIndex: fixed,
217
+ })
218
+ return result !== null
219
+ }
220
+ for (const candidate of domains[index]!) {
221
+ if (
222
+ candidate.angle < (chosen.at(-1)?.angle ?? -Infinity) - EPSILON ||
223
+ chosen.some((c) => forbidden.get(c)!.has(candidate))
224
+ )
225
+ continue
226
+ chosen.push(candidate)
227
+ if (remainingSitesCanMatch() && search(index + 1)) return true
228
+ chosen.pop()
229
+ if (
230
+ states > maximumOrderingStates ||
231
+ completeAssignments >= maximumCompleteAssignments
232
+ )
233
+ break
234
+ }
235
+ return false
236
+ }
237
+ search(0)
238
+ return result
239
+ }