@tscircuit/fanout-solver 0.0.63 → 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.
@@ -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
+ }
@@ -0,0 +1,167 @@
1
+ import type { SimpleRouteJson } from "@tscircuit/capacity-autorouter"
2
+ import { getCornerBandSide } from "./boundary-exit"
3
+ import { matchBusPlanLengths } from "./match-bus-lengths"
4
+ import type { RouteBusParams } from "./route-bus"
5
+ import {
6
+ routeViaMinimalWindingAlternativesSteps,
7
+ type RouteViaMinimalWindingProgress,
8
+ } from "./route-via-minimal-winding"
9
+ import type { Bounds, FanoutRoutePlan, PreparedBus } from "./types"
10
+
11
+ interface Params
12
+ extends Omit<RouteBusParams, "bus" | "targetLayer" | "acceptedPlans"> {
13
+ inputSrj: SimpleRouteJson
14
+ sharedBoundary: Bounds
15
+ preparedBuses: readonly PreparedBus[]
16
+ plans: readonly FanoutRoutePlan[]
17
+ }
18
+
19
+ /** Move a pair within its boundary band when its first route leaves no tuning room. */
20
+ export function* repairPeripheralBusLengthsSteps(
21
+ params: Params,
22
+ ): Generator<RouteViaMinimalWindingProgress, FanoutRoutePlan[] | null, void> {
23
+ const match = (
24
+ plans: readonly FanoutRoutePlan[],
25
+ buses = params.preparedBuses,
26
+ ) =>
27
+ matchBusPlanLengths({
28
+ plans,
29
+ preparedBuses: buses,
30
+ inputSrj: params.inputSrj,
31
+ sharedBoundary: params.sharedBoundary,
32
+ clearance: params.clearance,
33
+ allowBlindAndBuriedVias: params.allowBlindAndBuriedVias,
34
+ allowSameNetMerges: params.allowSameNetMerges,
35
+ })
36
+ const skew = (plans: readonly FanoutRoutePlan[]) =>
37
+ Math.max(...plans.map((plan) => plan.length)) -
38
+ Math.min(...plans.map((plan) => plan.length))
39
+ const repaired = new Set<string>()
40
+ let current = [...params.plans]
41
+ while (repaired.size < 3) {
42
+ const matched = match(current)
43
+ if (matched.plans) return matched.plans
44
+ const bus = matched.failedBus
45
+ if (
46
+ repaired.has(bus.busId) ||
47
+ bus.connections.length !== 2 ||
48
+ bus.termination.type !== "boundary" ||
49
+ !bus.exitEdge
50
+ )
51
+ return null
52
+ repaired.add(bus.busId)
53
+ const own = current.filter((plan) => plan.busId === bus.busId)
54
+ if (
55
+ own.length !== 2 ||
56
+ own.some((plan) => !plan.via || plan.additionalVias?.length) ||
57
+ own[0]!.targetLayer !== own[1]!.targetLayer
58
+ )
59
+ return null
60
+ const accepted = current.filter((plan) => plan.busId !== bus.busId)
61
+ const sourceEscapePaths = new Map(
62
+ own.map((plan) => [
63
+ plan.connectionIndex,
64
+ [
65
+ plan.sourcePoint,
66
+ ...plan.segments
67
+ .slice(0, plan.sourceEscapeSegmentCount ?? 1)
68
+ .map((segment) => segment.end),
69
+ ],
70
+ ]),
71
+ )
72
+ const horizontal = bus.exitEdge === "left" || bus.exitEdge === "right"
73
+ const axis = horizontal ? "y" : "x"
74
+ const minimum = horizontal
75
+ ? params.sharedBoundary.minY
76
+ : params.sharedBoundary.minX
77
+ const maximum = horizontal
78
+ ? params.sharedBoundary.maxY
79
+ : params.sharedBoundary.maxX
80
+ const middle = (minimum + maximum) / 2
81
+ const side = getCornerBandSide(bus.exitEdge, bus.preferredExit)
82
+ const pitch = params.traceWidth + params.clearance
83
+ const initialSkew = skew(own)
84
+ const candidates: FanoutRoutePlan[][] = []
85
+ let replacement: FanoutRoutePlan[] | null = null
86
+ search: for (const multiplier of [0, 1, -1, 2, -2, 3, -3, 4, -4]) {
87
+ const offset = multiplier * pitch
88
+ if (
89
+ own.some((plan) => {
90
+ const track = plan.exitPoint[axis] + offset
91
+ return (
92
+ track < minimum + params.traceWidth / 2 ||
93
+ track > maximum - params.traceWidth / 2 ||
94
+ (side === "minimum" && track >= middle) ||
95
+ (side === "maximum" && track <= middle)
96
+ )
97
+ })
98
+ )
99
+ continue
100
+ for (const laneBias of [0, -1, 1] as const) {
101
+ for (const routeOrder of [
102
+ [0, 1],
103
+ [1, 0],
104
+ ]) {
105
+ const alternatives = yield* routeViaMinimalWindingAlternativesSteps(
106
+ {
107
+ ...params,
108
+ bus,
109
+ targetLayer: own[0]!.targetLayer,
110
+ acceptedPlans: accepted,
111
+ terminals: bus.connections.map((connection) => {
112
+ const original = own.find(
113
+ (plan) => plan.connectionIndex === connection.connectionIndex,
114
+ )!
115
+ return {
116
+ connection,
117
+ viaPoint: original.via!.center,
118
+ exitPoint: {
119
+ ...original.exitPoint,
120
+ [axis]: original.exitPoint[axis] + offset,
121
+ },
122
+ }
123
+ }),
124
+ sourceEscapePaths,
125
+ reservedVias: undefined,
126
+ gridStep: pitch / 2,
127
+ gridStepDivisor: 2,
128
+ alignGridToPads: true,
129
+ maximumRouteOrderAttempts: 1,
130
+ routeOrder,
131
+ laneBias,
132
+ },
133
+ 1,
134
+ false,
135
+ )
136
+ if (!alternatives.length || skew(alternatives[0]!) >= initialSkew)
137
+ continue
138
+ const candidate = alternatives[0]!.map((plan) => ({
139
+ ...plan,
140
+ cornerBandSide: side,
141
+ }))
142
+ candidates.push(candidate)
143
+ // A small remaining deficit is inexpensive to tune. Larger deficits
144
+ // are attempted below after collecting the best available geometry.
145
+ if (skew(candidate) > 2 * bus.maxLengthSkew!) continue
146
+ const tuned = match([...accepted, ...candidate], [bus])
147
+ if (!tuned.plans) continue
148
+ replacement = tuned.plans
149
+ break search
150
+ }
151
+ }
152
+ }
153
+ if (!replacement) {
154
+ for (const candidate of candidates
155
+ .toSorted((a, b) => skew(a) - skew(b))
156
+ .slice(0, 3)) {
157
+ const tuned = match([...accepted, ...candidate], [bus])
158
+ if (!tuned.plans) continue
159
+ replacement = tuned.plans
160
+ break
161
+ }
162
+ }
163
+ if (!replacement) return null
164
+ current = replacement
165
+ }
166
+ return match(current).plans
167
+ }