@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.
- package/README.md +13 -0
- package/lib/fanout-solver.ts +260 -9
- package/lib/get-dogbone-side-variants.ts +26 -0
- package/lib/refine-adaptive-plane-reservations.ts +94 -0
- package/lib/route-bus.ts +375 -134
- package/lib/route-via-minimal-winding.ts +28 -0
- package/lib/select-compatible-candidates.ts +253 -0
- package/lib/should-use-adaptive-dense-plane-routing.ts +95 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|