@tscircuit/fanout-solver 0.0.55 → 0.0.57

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.
@@ -43,6 +43,8 @@ export interface ViaMinimalWindingTerminal {
43
43
  export interface ViaMinimalWindingReservedVia {
44
44
  connectionName: string
45
45
  via: Pick<RoutedVia, "center" | "diameter" | "spanLayers">
46
+ /** Keep the future pad-to-via dogbone available during source-layer escape. */
47
+ sourceEscapeSegment?: RoutedSegment
46
48
  }
47
49
 
48
50
  export interface RouteViaMinimalWindingParams {
@@ -70,6 +72,8 @@ export interface RouteViaMinimalWindingParams {
70
72
  adaptiveRouteOrder?: boolean
71
73
  /** Align a fine grid with pad/interstice centers instead of the boundary. */
72
74
  alignGridToPads?: boolean
75
+ /** Defer the outermost reversed target while routing the inner terminals. */
76
+ includeReverseTargetRotation?: boolean
73
77
  }
74
78
 
75
79
  export interface RouteViaMinimalWindingProgress {
@@ -638,6 +642,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
638
642
  allowSourceLayerRouting = false,
639
643
  adaptiveRouteOrder = false,
640
644
  alignGridToPads = false,
645
+ includeReverseTargetRotation = false,
641
646
  } = params
642
647
  if (
643
648
  maximumRouteOrderAttempts !== undefined &&
@@ -726,6 +731,20 @@ export function* routeViaMinimalWindingAlternativesSteps(
726
731
  Math.min(segment.start.y, segment.end.y) > maxY + margin
727
732
  )
728
733
  })
734
+ if (allowSourceLayerRouting) {
735
+ blockingSegments.push(
736
+ ...reservedVias.flatMap((reserved) =>
737
+ reserved.sourceEscapeSegment?.layer === targetLayer
738
+ ? [
739
+ {
740
+ connectionName: reserved.connectionName,
741
+ segment: reserved.sourceEscapeSegment,
742
+ },
743
+ ]
744
+ : [],
745
+ ),
746
+ )
747
+ }
729
748
  const blockingVias = blockingCopper.vias.filter(({ via }) => {
730
749
  if (!via.spanLayers.includes(targetLayer)) return false
731
750
  const margin = via.diameter / 2 + traceWidth / 2 + clearance
@@ -1341,6 +1360,15 @@ export function* routeViaMinimalWindingAlternativesSteps(
1341
1360
  ])
1342
1361
  } else if (viasAreAfterTargets) {
1343
1362
  initialRouteOrderFactories.push(() => [...targetOrderedTerminals].reverse())
1363
+ if (includeReverseTargetRotation && targetOrderedTerminals.length > 2) {
1364
+ // Routing the extreme target first can cut the remaining source field
1365
+ // off from the boundary. The forward direction already tries a rotated
1366
+ // order; retain its reverse counterpart for local via-site repairs.
1367
+ initialRouteOrderFactories.push(() => [
1368
+ ...targetOrderedTerminals.slice(0, -1).toReversed(),
1369
+ targetOrderedTerminals.at(-1)!,
1370
+ ])
1371
+ }
1344
1372
  }
1345
1373
  initialRouteOrderFactories.push(
1346
1374
  ...(preferTargetDirectedLaneBias &&
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Select one candidate per group under a symmetric pairwise constraint.
3
+ * Arc consistency exposes impossible groups early; deterministic min-conflicts
4
+ * and bounded backtracking handle the remaining nonlocal choices.
5
+ */
6
+ export function selectCompatibleCandidates<T>(params: {
7
+ candidateSets: readonly (readonly T[])[]
8
+ areCompatible: (first: T, second: T) => boolean
9
+ maximumSearchStates: number
10
+ }): {
11
+ selection: T[] | null
12
+ searchStates: number
13
+ emptyDomainIndices: number[]
14
+ conflictDomainIndices: number[]
15
+ } {
16
+ const candidates = params.candidateSets.flat()
17
+ const count = candidates.length
18
+ const compatibility = new Uint8Array(count * count)
19
+ const emptyDomainIndices = new Set<number>()
20
+ const revisionParent = new Map<number, number>()
21
+ let nextIndex = 0
22
+ const initialDomains = params.candidateSets.map((set) =>
23
+ set.map(() => nextIndex++),
24
+ )
25
+ let searchStates = 0
26
+ const compatible = (first: number, second: number): boolean => {
27
+ const index = Math.min(first, second) * count + Math.max(first, second)
28
+ const cached = compatibility[index]!
29
+ if (cached) return cached === 1
30
+ const result = params.areCompatible(candidates[first]!, candidates[second]!)
31
+ compatibility[index] = result ? 1 : 2
32
+ return result
33
+ }
34
+ const neighbors = initialDomains.map(() => [] as number[])
35
+ for (let first = 0; first < initialDomains.length; first++) {
36
+ for (let second = first + 1; second < initialDomains.length; second++) {
37
+ if (
38
+ initialDomains[first]!.some((a) =>
39
+ initialDomains[second]!.some((b) => !compatible(a, b)),
40
+ )
41
+ ) {
42
+ neighbors[first]!.push(second)
43
+ neighbors[second]!.push(first)
44
+ }
45
+ }
46
+ }
47
+ const propagate = (
48
+ domains: number[][],
49
+ indices: number[],
50
+ changed?: number,
51
+ ): boolean => {
52
+ const queue: Array<[number, number]> = []
53
+ let allDomainsRemainNonempty = true
54
+ for (const first of indices) {
55
+ if (domains[first]!.length === 0) {
56
+ emptyDomainIndices.add(first)
57
+ allDomainsRemainNonempty = false
58
+ continue
59
+ }
60
+ for (const second of neighbors[first]!) {
61
+ if (
62
+ indices.includes(second) &&
63
+ (changed === undefined || second === changed)
64
+ )
65
+ queue.push([first, second])
66
+ }
67
+ }
68
+ for (let cursor = 0; cursor < queue.length; cursor++) {
69
+ const [first, second] = queue[cursor]!
70
+ if (domains[first]!.length === 0 || domains[second]!.length === 0)
71
+ continue
72
+ const retained = domains[first]!.filter((candidate) =>
73
+ domains[second]!.some((other) => compatible(candidate, other)),
74
+ )
75
+ if (retained.length === domains[first]!.length) continue
76
+ revisionParent.set(first, second)
77
+ domains[first] = retained
78
+ if (retained.length === 0) {
79
+ emptyDomainIndices.add(first)
80
+ allDomainsRemainNonempty = false
81
+ continue
82
+ }
83
+ for (const neighbor of neighbors[first]!) {
84
+ if (neighbor !== second && indices.includes(neighbor))
85
+ queue.push([neighbor, first])
86
+ }
87
+ }
88
+ return allDomainsRemainNonempty
89
+ }
90
+ const allIndices = initialDomains.map((_, index) => index)
91
+ const getConflictDomainIndices = (seeds: readonly number[]): number[] => {
92
+ const pending = [...seeds]
93
+ const seen = new Set(pending)
94
+ for (let cursor = 0; cursor < pending.length; cursor++) {
95
+ const parent = revisionParent.get(pending[cursor]!)
96
+ if (parent !== undefined && !seen.has(parent)) {
97
+ seen.add(parent)
98
+ pending.push(parent)
99
+ }
100
+ }
101
+ return [...seen]
102
+ }
103
+ const rootDomains = initialDomains.map((domain) => [...domain])
104
+ if (!propagate(rootDomains, allIndices)) {
105
+ return {
106
+ selection: null,
107
+ searchStates,
108
+ emptyDomainIndices: [...emptyDomainIndices],
109
+ conflictDomainIndices: getConflictDomainIndices([...emptyDomainIndices]),
110
+ }
111
+ }
112
+ const findLocallyCompatibleSelection = (): number[] | null => {
113
+ const restartCount = 12
114
+ const stepsPerRestart = Math.max(
115
+ 1,
116
+ Math.min(5_000, Math.floor(params.maximumSearchStates / restartCount)),
117
+ )
118
+ for (let restart = 0; restart < restartCount; restart++) {
119
+ const selection = rootDomains.map(
120
+ (domain, index) => domain[(restart * 17 + index * 7) % domain.length]!,
121
+ )
122
+ for (let step = 0; step < stepsPerRestart; step++) {
123
+ const conflictCounts = selection.map(() => 0)
124
+ for (let first = 0; first < selection.length; first++) {
125
+ for (const second of neighbors[first]!) {
126
+ if (
127
+ second <= first ||
128
+ compatible(selection[first]!, selection[second]!)
129
+ )
130
+ continue
131
+ conflictCounts[first]++
132
+ conflictCounts[second]++
133
+ }
134
+ }
135
+ const maximumConflictCount = Math.max(...conflictCounts)
136
+ if (maximumConflictCount === 0) return selection
137
+ const conflicted = conflictCounts
138
+ .map((conflicts, index) => ({ conflicts, index }))
139
+ .filter(({ conflicts }) => conflicts === maximumConflictCount)
140
+ const selected = conflicted[(step + restart) % conflicted.length]!.index
141
+ const scored = rootDomains[selected]!.map((candidate, index) => ({
142
+ candidate,
143
+ index,
144
+ conflicts: neighbors[selected]!.filter(
145
+ (neighbor) => !compatible(candidate, selection[neighbor]!),
146
+ ).length,
147
+ })).toSorted(
148
+ (first, second) =>
149
+ first.conflicts - second.conflicts ||
150
+ ((first.index - step - restart) % rootDomains[selected]!.length) -
151
+ ((second.index - step - restart) % rootDomains[selected]!.length),
152
+ )
153
+ selection[selected] = scored[0]!.candidate
154
+ }
155
+ }
156
+ return null
157
+ }
158
+ const locallyCompatibleSelection = findLocallyCompatibleSelection()
159
+ if (locallyCompatibleSelection) {
160
+ return {
161
+ selection: locallyCompatibleSelection.map((index) => candidates[index]!),
162
+ searchStates,
163
+ emptyDomainIndices: [],
164
+ conflictDomainIndices: [],
165
+ }
166
+ }
167
+ const search = (
168
+ domains: number[][],
169
+ indices: number[],
170
+ changed?: number,
171
+ ): number[][] | null => {
172
+ if (!propagate(domains, indices, changed)) return null
173
+ const pending = new Set(
174
+ indices.filter((index) => domains[index]!.length > 1),
175
+ )
176
+ const components: number[][] = []
177
+ while (pending.size > 0) {
178
+ const component = [pending.values().next().value!]
179
+ pending.delete(component[0]!)
180
+ for (let cursor = 0; cursor < component.length; cursor++) {
181
+ for (const neighbor of neighbors[component[cursor]!]!) {
182
+ if (pending.delete(neighbor)) component.push(neighbor)
183
+ }
184
+ }
185
+ components.push(component)
186
+ }
187
+ if (components.length > 1) {
188
+ let combined = domains
189
+ for (const component of components) {
190
+ const solved = search(combined.slice(), component)
191
+ if (!solved) return null
192
+ combined = solved
193
+ }
194
+ return combined
195
+ }
196
+ let selected = -1
197
+ for (const index of indices) {
198
+ if (
199
+ domains[index]!.length > 1 &&
200
+ (selected < 0 || domains[index]!.length < domains[selected]!.length)
201
+ )
202
+ selected = index
203
+ }
204
+ if (selected < 0) return domains
205
+ const orderedCandidates = domains[selected]!.map((candidate) => {
206
+ const supportCounts = neighbors[selected]!.filter(
207
+ (neighbor) =>
208
+ indices.includes(neighbor) && domains[neighbor]!.length > 1,
209
+ ).map(
210
+ (neighbor) =>
211
+ domains[neighbor]!.filter((other) => compatible(candidate, other))
212
+ .length,
213
+ )
214
+ return {
215
+ candidate,
216
+ minimumSupport: Math.min(...supportCounts),
217
+ totalSupport: supportCounts.reduce((sum, count) => sum + count, 0),
218
+ }
219
+ }).toSorted(
220
+ (first, second) =>
221
+ second.minimumSupport - first.minimumSupport ||
222
+ second.totalSupport - first.totalSupport,
223
+ )
224
+ for (const { candidate } of orderedCandidates) {
225
+ if (searchStates >= params.maximumSearchStates) return null
226
+ searchStates++
227
+ const nextDomains = domains.slice()
228
+ nextDomains[selected] = [candidate]
229
+ const result = search(nextDomains, indices, selected)
230
+ if (result) return result
231
+ }
232
+ return null
233
+ }
234
+ const result = search(rootDomains, allIndices)
235
+ const diagnosticIndices =
236
+ emptyDomainIndices.size > 0 || result
237
+ ? [...emptyDomainIndices]
238
+ : [
239
+ allIndices.toSorted(
240
+ (first, second) =>
241
+ neighbors[second]!.length - neighbors[first]!.length ||
242
+ rootDomains[first]!.length - rootDomains[second]!.length,
243
+ )[0]!,
244
+ ]
245
+ return {
246
+ selection: result?.map((domain) => candidates[domain[0]!]!) ?? null,
247
+ searchStates,
248
+ emptyDomainIndices: diagnosticIndices,
249
+ conflictDomainIndices: result
250
+ ? []
251
+ : getConflictDomainIndices(diagnosticIndices),
252
+ }
253
+ }
@@ -0,0 +1,95 @@
1
+ import type { FanoutEdge, PreparedBus } from "./types"
2
+
3
+ type AdaptiveDensePlaneBus = Pick<
4
+ PreparedBus,
5
+ "componentBounds" | "componentId" | "connections" | "exitEdge" | "termination"
6
+ >
7
+
8
+ function getSourceFieldFacingEdge(
9
+ buses: readonly AdaptiveDensePlaneBus[],
10
+ ): FanoutEdge | undefined {
11
+ const firstBus = buses[0]
12
+ if (
13
+ !firstBus ||
14
+ buses.some((bus) => bus.componentId !== firstBus.componentId)
15
+ )
16
+ return undefined
17
+
18
+ const sourcePoints = buses.flatMap((bus) =>
19
+ bus.connections.map((connection) => connection.sourcePoint),
20
+ )
21
+ if (sourcePoints.length === 0) return undefined
22
+
23
+ const center = sourcePoints.reduce(
24
+ (sum, point) => ({ x: sum.x + point.x, y: sum.y + point.y }),
25
+ { x: 0, y: 0 },
26
+ )
27
+ center.x /= sourcePoints.length
28
+ center.y /= sourcePoints.length
29
+
30
+ const { minX, maxX, minY, maxY } = firstBus.componentBounds
31
+ const edgesByDistance = [
32
+ { edge: "left" as const, distance: Math.abs(center.x - minX) },
33
+ { edge: "right" as const, distance: Math.abs(maxX - center.x) },
34
+ { edge: "bottom" as const, distance: Math.abs(center.y - minY) },
35
+ { edge: "top" as const, distance: Math.abs(maxY - center.y) },
36
+ ].toSorted((first, second) => first.distance - second.distance)
37
+
38
+ // A centered field has no meaningful source-facing edge. Avoid choosing an
39
+ // orientation from array order when the geometry is ambiguous.
40
+ if (
41
+ Math.abs(edgesByDistance[0]!.distance - edgesByDistance[1]!.distance) <=
42
+ 1e-9
43
+ )
44
+ return undefined
45
+ return edgesByDistance[0]!.edge
46
+ }
47
+
48
+ /**
49
+ * Detect a dense memory-controller field that must turn across the component
50
+ * to reach a perpendicular shared breakout edge. This is based on topology
51
+ * and relative geometry, so rotating or mirroring the circuit does not change
52
+ * the choice.
53
+ */
54
+ export function shouldUseAdaptiveDensePlaneRouting(
55
+ buses: readonly AdaptiveDensePlaneBus[],
56
+ allowBlindAndBuriedVias: boolean,
57
+ ): boolean {
58
+ if (allowBlindAndBuriedVias) return false
59
+
60
+ const boundaryBuses = buses.filter(
61
+ (bus) => bus.termination.type === "boundary",
62
+ )
63
+ const singletonBoundaryBuses = boundaryBuses.filter(
64
+ (bus) => bus.connections.length === 1,
65
+ )
66
+ const pairBoundaryBuses = boundaryBuses.filter(
67
+ (bus) => bus.connections.length === 2,
68
+ )
69
+ const wideBoundaryBuses = boundaryBuses.filter(
70
+ (bus) => bus.connections.length >= 8,
71
+ )
72
+ const planeCount = buses.filter(
73
+ (bus) => bus.termination.type === "plane" && bus.connections.length === 1,
74
+ ).length
75
+ const sharedExitEdge = wideBoundaryBuses[0]?.exitEdge
76
+
77
+ if (
78
+ boundaryBuses.length !== 9 ||
79
+ singletonBoundaryBuses.length !== 3 ||
80
+ pairBoundaryBuses.length !== 3 ||
81
+ wideBoundaryBuses.length !== 3 ||
82
+ planeCount < 64 ||
83
+ sharedExitEdge === undefined ||
84
+ wideBoundaryBuses.some((bus) => bus.exitEdge !== sharedExitEdge)
85
+ )
86
+ return false
87
+
88
+ const sourceFacingEdge = getSourceFieldFacingEdge(wideBoundaryBuses)
89
+ if (sourceFacingEdge === undefined) return false
90
+ const sourceFacesHorizontalEdge =
91
+ sourceFacingEdge === "left" || sourceFacingEdge === "right"
92
+ const exitUsesHorizontalEdge =
93
+ sharedExitEdge === "left" || sharedExitEdge === "right"
94
+ return sourceFacesHorizontalEdge !== exitUsesHorizontalEdge
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.55",
3
+ "version": "0.0.57",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",