@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.
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * View horizontal fanout geometry from its opposite edge. A shared memo keeps
3
+ * pad identity intact: clearance checks compare a plan's sourceObstacle with
4
+ * the same obstacle in the transformed SRJ.
5
+ */
6
+ export function reflectFanoutX<T>(value: T): T {
7
+ const memo = new Map<object, unknown>()
8
+ const reflect = (item: unknown): unknown => {
9
+ if (item === null || typeof item !== "object") return item
10
+ if (memo.has(item)) return memo.get(item)
11
+ if (item instanceof Map) {
12
+ const result = new Map()
13
+ memo.set(item, result)
14
+ for (const [key, entry] of item) result.set(key, reflect(entry))
15
+ return result
16
+ }
17
+ if (item instanceof Set) {
18
+ const result = new Set(item)
19
+ memo.set(item, result)
20
+ return result
21
+ }
22
+ if (Array.isArray(item)) {
23
+ const result: unknown[] = []
24
+ memo.set(item, result)
25
+ for (const entry of item) result.push(reflect(entry))
26
+ return result
27
+ }
28
+ const original = item as Record<string, unknown>
29
+ const result: Record<string, unknown> = {}
30
+ memo.set(item, result)
31
+ for (const [key, entry] of Object.entries(original))
32
+ result[key] = reflect(entry)
33
+ if (typeof original.x === "number" && typeof original.y === "number")
34
+ result.x = -original.x
35
+ if (
36
+ typeof original.minX === "number" &&
37
+ typeof original.maxX === "number"
38
+ ) {
39
+ result.minX = -original.maxX
40
+ result.maxX = -original.minX
41
+ }
42
+ if (Array.isArray(original.xCoordinates))
43
+ result.xCoordinates = original.xCoordinates
44
+ .map((x: number) => -x)
45
+ .sort((a, b) => a - b)
46
+ if (typeof original.ccwRotationDegrees === "number")
47
+ result.ccwRotationDegrees = -original.ccwRotationDegrees
48
+ const edge = (direction: string) =>
49
+ direction === "left"
50
+ ? "right"
51
+ : direction === "right"
52
+ ? "left"
53
+ : direction
54
+ for (const key of ["direction", "exitEdge", "preferredExit"] as const) {
55
+ if (typeof original[key] === "string")
56
+ result[key] = original[key].split("-").map(edge).join("-")
57
+ }
58
+ if (
59
+ original.cornerBandSide &&
60
+ (original.exitEdge === "top" || original.exitEdge === "bottom")
61
+ )
62
+ result.cornerBandSide =
63
+ original.cornerBandSide === "minimum" ? "maximum" : "minimum"
64
+ return result
65
+ }
66
+ return reflect(value) as T
67
+ }