@tscircuit/fanout-solver 0.0.38 → 0.0.39

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,584 @@
1
+ import type { Obstacle } from "@tscircuit/capacity-autorouter"
2
+ import {
3
+ distance,
4
+ distancePointToObstacle,
5
+ distancePointToSegment,
6
+ distanceSegmentToObstacle,
7
+ segmentsAreClear,
8
+ } from "./geometry"
9
+ import type {
10
+ FanoutDirection,
11
+ Point2D,
12
+ PreparedBus,
13
+ PreparedConnection,
14
+ RoutedSegment,
15
+ } from "./types"
16
+
17
+ const EPSILON = 1e-9
18
+ const DEFAULT_MAXIMUM_SEARCH_STATES = 100_000
19
+
20
+ export interface DogboneViaSiteGeometryRules {
21
+ viaDiameter: number
22
+ viaHoleDiameter?: number
23
+ traceWidth: number
24
+ clearance: number
25
+ /** Defaults to `clearance` when a hole diameter is supplied. */
26
+ holeToHoleClearance?: number
27
+ /** Bounds the deterministic backtracking search across all components. */
28
+ maximumSearchStates?: number
29
+ /**
30
+ * Optional bounded-search preference for boundary-bus dogbones. The sign
31
+ * refers to the axis perpendicular to each bus's local escape direction.
32
+ */
33
+ preferredBoundaryPerpendicularSideByBusId?: ReadonlyMap<string, -1 | 1>
34
+ /** Prefer the local outward or inward half-pitch row for a boundary bus. */
35
+ preferBoundaryOutwardByBusId?: ReadonlyMap<string, boolean>
36
+ /** True only when the two connections are allowed to merge copper. */
37
+ canShareCopper?: (
38
+ firstConnectionIndex: number,
39
+ secondConnectionIndex: number,
40
+ ) => boolean
41
+ }
42
+
43
+ interface ComponentConnection {
44
+ preparedConnection: PreparedConnection
45
+ busId: string
46
+ direction: FanoutDirection
47
+ terminationType: PreparedBus["termination"]["type"]
48
+ }
49
+
50
+ interface ComponentMatchingInput {
51
+ componentId: string
52
+ connections: ComponentConnection[]
53
+ obstacles: Obstacle[]
54
+ xCoordinates: number[]
55
+ yCoordinates: number[]
56
+ pitchX: number
57
+ pitchY: number
58
+ }
59
+
60
+ interface ViaSiteCandidate {
61
+ connectionIndex: number
62
+ point: Point2D
63
+ sourceSegment: RoutedSegment
64
+ outwardRank: number
65
+ }
66
+
67
+ interface ConnectionCandidates {
68
+ connection: ComponentConnection
69
+ candidates: ViaSiteCandidate[]
70
+ }
71
+
72
+ function assertGeometryRules(rules: DogboneViaSiteGeometryRules): number {
73
+ for (const [name, value] of [
74
+ ["viaDiameter", rules.viaDiameter],
75
+ ["traceWidth", rules.traceWidth],
76
+ ] as const) {
77
+ if (!Number.isFinite(value) || value <= 0) {
78
+ throw new Error(
79
+ `FanoutSolver: dogbone ${name} must be a positive finite number, received ${value}`,
80
+ )
81
+ }
82
+ }
83
+ if (!Number.isFinite(rules.clearance) || rules.clearance < 0) {
84
+ throw new Error(
85
+ `FanoutSolver: dogbone clearance must be a non-negative finite number, received ${rules.clearance}`,
86
+ )
87
+ }
88
+ if (
89
+ rules.viaHoleDiameter !== undefined &&
90
+ (!Number.isFinite(rules.viaHoleDiameter) || rules.viaHoleDiameter <= 0)
91
+ ) {
92
+ throw new Error(
93
+ `FanoutSolver: dogbone viaHoleDiameter must be a positive finite number, received ${rules.viaHoleDiameter}`,
94
+ )
95
+ }
96
+ if (
97
+ rules.holeToHoleClearance !== undefined &&
98
+ (!Number.isFinite(rules.holeToHoleClearance) ||
99
+ rules.holeToHoleClearance < 0)
100
+ ) {
101
+ throw new Error(
102
+ `FanoutSolver: dogbone holeToHoleClearance must be a non-negative finite number, received ${rules.holeToHoleClearance}`,
103
+ )
104
+ }
105
+ if (
106
+ rules.holeToHoleClearance !== undefined &&
107
+ rules.viaHoleDiameter === undefined
108
+ ) {
109
+ throw new Error(
110
+ "FanoutSolver: dogbone holeToHoleClearance requires viaHoleDiameter",
111
+ )
112
+ }
113
+ const maximumSearchStates =
114
+ rules.maximumSearchStates ?? DEFAULT_MAXIMUM_SEARCH_STATES
115
+ if (!Number.isInteger(maximumSearchStates) || maximumSearchStates < 1) {
116
+ throw new Error(
117
+ `FanoutSolver: dogbone maximumSearchStates must be a positive integer, received ${maximumSearchStates}`,
118
+ )
119
+ }
120
+ return maximumSearchStates
121
+ }
122
+
123
+ function uniqueSortedCoordinates(values: readonly number[]): number[] {
124
+ const result: number[] = []
125
+ for (const value of values.toSorted((first, second) => first - second)) {
126
+ if (!Number.isFinite(value)) continue
127
+ if (result.length === 0 || Math.abs(result.at(-1)! - value) > EPSILON) {
128
+ result.push(value)
129
+ }
130
+ }
131
+ return result
132
+ }
133
+
134
+ function getComponentMatchingInputs(
135
+ preparedBuses: readonly PreparedBus[],
136
+ ): ComponentMatchingInput[] {
137
+ const byComponent = new Map<string, ComponentMatchingInput>()
138
+ const componentByConnectionIndex = new Map<number, string>()
139
+
140
+ for (const bus of preparedBuses) {
141
+ let component = byComponent.get(bus.componentId)
142
+ if (!component) {
143
+ component = {
144
+ componentId: bus.componentId,
145
+ connections: [],
146
+ obstacles: [],
147
+ xCoordinates: [],
148
+ yCoordinates: [],
149
+ pitchX: Number.POSITIVE_INFINITY,
150
+ pitchY: Number.POSITIVE_INFINITY,
151
+ }
152
+ byComponent.set(bus.componentId, component)
153
+ }
154
+
155
+ component.xCoordinates.push(...bus.xCoordinates)
156
+ component.yCoordinates.push(...bus.yCoordinates)
157
+ if (Number.isFinite(bus.pitchX) && bus.pitchX > EPSILON) {
158
+ component.pitchX = Math.min(component.pitchX, bus.pitchX)
159
+ }
160
+ if (Number.isFinite(bus.pitchY) && bus.pitchY > EPSILON) {
161
+ component.pitchY = Math.min(component.pitchY, bus.pitchY)
162
+ }
163
+ for (const obstacle of bus.componentObstacles) {
164
+ if (!component.obstacles.includes(obstacle)) {
165
+ component.obstacles.push(obstacle)
166
+ }
167
+ }
168
+ for (const preparedConnection of bus.connections) {
169
+ const existingComponent = componentByConnectionIndex.get(
170
+ preparedConnection.connectionIndex,
171
+ )
172
+ if (existingComponent !== undefined) {
173
+ if (existingComponent !== bus.componentId) {
174
+ throw new Error(
175
+ `FanoutSolver: connection index ${preparedConnection.connectionIndex} belongs to multiple components`,
176
+ )
177
+ }
178
+ continue
179
+ }
180
+ componentByConnectionIndex.set(
181
+ preparedConnection.connectionIndex,
182
+ bus.componentId,
183
+ )
184
+ component.connections.push({
185
+ preparedConnection,
186
+ busId: bus.busId,
187
+ direction: bus.direction,
188
+ terminationType: bus.termination.type,
189
+ })
190
+ }
191
+ }
192
+
193
+ return [...byComponent.values()]
194
+ .map((component) => ({
195
+ ...component,
196
+ connections: component.connections.toSorted(
197
+ (first, second) =>
198
+ first.preparedConnection.connectionIndex -
199
+ second.preparedConnection.connectionIndex,
200
+ ),
201
+ xCoordinates: uniqueSortedCoordinates(component.xCoordinates),
202
+ yCoordinates: uniqueSortedCoordinates(component.yCoordinates),
203
+ }))
204
+ .toSorted((first, second) =>
205
+ first.componentId.localeCompare(second.componentId),
206
+ )
207
+ }
208
+
209
+ function getInterstitialCoordinates(params: {
210
+ coordinates: readonly number[]
211
+ pitch: number
212
+ }): number[] {
213
+ const { coordinates, pitch } = params
214
+ if (coordinates.length === 0 || !Number.isFinite(pitch) || pitch <= EPSILON) {
215
+ return []
216
+ }
217
+ const interstitialCoordinates = [coordinates[0]! - pitch / 2]
218
+ for (let index = 1; index < coordinates.length; index++) {
219
+ interstitialCoordinates.push(
220
+ (coordinates[index - 1]! + coordinates[index]!) / 2,
221
+ )
222
+ }
223
+ interstitialCoordinates.push(coordinates.at(-1)! + pitch / 2)
224
+ return uniqueSortedCoordinates(interstitialCoordinates)
225
+ }
226
+
227
+ function getAdjacentInterstitialCoordinates(params: {
228
+ sourceCoordinate: number
229
+ coordinates: readonly number[]
230
+ pitch: number
231
+ }): number[] {
232
+ const { sourceCoordinate, coordinates, pitch } = params
233
+ const interstitialCoordinates = getInterstitialCoordinates({
234
+ coordinates,
235
+ pitch,
236
+ })
237
+ const before = interstitialCoordinates
238
+ .filter((coordinate) => coordinate < sourceCoordinate - EPSILON)
239
+ .at(-1)
240
+ const after = interstitialCoordinates.find(
241
+ (coordinate) => coordinate > sourceCoordinate + EPSILON,
242
+ )
243
+ return [before, after].filter(
244
+ (coordinate): coordinate is number => coordinate !== undefined,
245
+ )
246
+ }
247
+
248
+ function directSegmentIsStraightOr45(start: Point2D, end: Point2D): boolean {
249
+ const absoluteX = Math.abs(end.x - start.x)
250
+ const absoluteY = Math.abs(end.y - start.y)
251
+ return (
252
+ absoluteX <= EPSILON ||
253
+ absoluteY <= EPSILON ||
254
+ Math.abs(absoluteX - absoluteY) <= EPSILON
255
+ )
256
+ }
257
+
258
+ function getOutwardRank(params: {
259
+ source: Point2D
260
+ site: Point2D
261
+ direction: FanoutDirection
262
+ }): number {
263
+ const { source, site, direction } = params
264
+ const outwardDisplacement =
265
+ direction === "right"
266
+ ? site.x - source.x
267
+ : direction === "left"
268
+ ? source.x - site.x
269
+ : direction === "up"
270
+ ? site.y - source.y
271
+ : source.y - site.y
272
+ return outwardDisplacement > EPSILON
273
+ ? 0
274
+ : Math.abs(outwardDisplacement) <= EPSILON
275
+ ? 1
276
+ : 2
277
+ }
278
+
279
+ function viaSiteClearsObstacles(params: {
280
+ point: Point2D
281
+ obstacles: readonly Obstacle[]
282
+ viaDiameter: number
283
+ clearance: number
284
+ }): boolean {
285
+ const { point, obstacles, viaDiameter, clearance } = params
286
+ const requiredClearance = viaDiameter / 2 + clearance
287
+ return obstacles.every(
288
+ (obstacle) =>
289
+ distancePointToObstacle(point, obstacle) >= requiredClearance - EPSILON,
290
+ )
291
+ }
292
+
293
+ function sourceSegmentClearsOtherObstacles(params: {
294
+ segment: RoutedSegment
295
+ sourceObstacle: Obstacle
296
+ obstacles: readonly Obstacle[]
297
+ clearance: number
298
+ }): boolean {
299
+ const { segment, sourceObstacle, obstacles, clearance } = params
300
+ const requiredClearance = segment.width / 2 + clearance
301
+ return obstacles.every(
302
+ (obstacle) =>
303
+ obstacle === sourceObstacle ||
304
+ distanceSegmentToObstacle(segment, obstacle) >=
305
+ requiredClearance - EPSILON,
306
+ )
307
+ }
308
+
309
+ function getConnectionCandidates(params: {
310
+ connection: ComponentConnection
311
+ component: ComponentMatchingInput
312
+ rules: DogboneViaSiteGeometryRules
313
+ }): ViaSiteCandidate[] {
314
+ const { connection, component, rules } = params
315
+ const { preparedConnection, direction } = connection
316
+ const source = {
317
+ x: preparedConnection.sourcePoint.x,
318
+ y: preparedConnection.sourcePoint.y,
319
+ }
320
+ const adjacentX = getAdjacentInterstitialCoordinates({
321
+ sourceCoordinate: source.x,
322
+ coordinates: component.xCoordinates,
323
+ pitch: component.pitchX,
324
+ })
325
+ const adjacentY = getAdjacentInterstitialCoordinates({
326
+ sourceCoordinate: source.y,
327
+ coordinates: component.yCoordinates,
328
+ pitch: component.pitchY,
329
+ })
330
+ const rawPoints: Point2D[] = [
331
+ ...adjacentX.map((x) => ({ x, y: source.y })),
332
+ ...adjacentY.map((y) => ({ x: source.x, y })),
333
+ ...adjacentX.flatMap((x) => adjacentY.map((y) => ({ x, y }))),
334
+ ]
335
+ const uniquePoints: Point2D[] = []
336
+ for (const point of rawPoints) {
337
+ if (
338
+ !uniquePoints.some((candidate) => distance(candidate, point) <= EPSILON)
339
+ ) {
340
+ uniquePoints.push(point)
341
+ }
342
+ }
343
+
344
+ const candidates: ViaSiteCandidate[] = []
345
+ for (const point of uniquePoints) {
346
+ if (
347
+ connection.terminationType === "plane" &&
348
+ !directSegmentIsStraightOr45(source, point)
349
+ ) {
350
+ continue
351
+ }
352
+ if (
353
+ !viaSiteClearsObstacles({
354
+ point,
355
+ obstacles: component.obstacles,
356
+ viaDiameter: rules.viaDiameter,
357
+ clearance: rules.clearance,
358
+ })
359
+ ) {
360
+ continue
361
+ }
362
+ const sourceSegment: RoutedSegment = {
363
+ start: source,
364
+ end: point,
365
+ width: rules.traceWidth,
366
+ layer: preparedConnection.sourceLayer,
367
+ }
368
+ if (
369
+ !sourceSegmentClearsOtherObstacles({
370
+ segment: sourceSegment,
371
+ sourceObstacle: preparedConnection.sourceObstacle,
372
+ obstacles: component.obstacles,
373
+ clearance: rules.clearance,
374
+ })
375
+ ) {
376
+ continue
377
+ }
378
+ candidates.push({
379
+ connectionIndex: preparedConnection.connectionIndex,
380
+ point,
381
+ sourceSegment,
382
+ outwardRank: getOutwardRank({ source, site: point, direction }),
383
+ })
384
+ }
385
+
386
+ const preferredPerpendicularSide =
387
+ connection.terminationType === "boundary"
388
+ ? rules.preferredBoundaryPerpendicularSideByBusId?.get(connection.busId)
389
+ : undefined
390
+ const preferOutward =
391
+ connection.terminationType === "boundary"
392
+ ? (rules.preferBoundaryOutwardByBusId?.get(connection.busId) ?? true)
393
+ : true
394
+ const getPerpendicularPreferenceRank = (
395
+ candidate: ViaSiteCandidate,
396
+ ): number => {
397
+ if (preferredPerpendicularSide === undefined) return 0
398
+ const displacement =
399
+ direction === "left" || direction === "right"
400
+ ? candidate.point.y - source.y
401
+ : candidate.point.x - source.x
402
+ return displacement * preferredPerpendicularSide > EPSILON
403
+ ? 0
404
+ : Math.abs(displacement) <= EPSILON
405
+ ? 1
406
+ : 2
407
+ }
408
+ return candidates.toSorted(
409
+ (first, second) =>
410
+ (preferOutward
411
+ ? first.outwardRank - second.outwardRank
412
+ : second.outwardRank - first.outwardRank) ||
413
+ getPerpendicularPreferenceRank(first) -
414
+ getPerpendicularPreferenceRank(second) ||
415
+ distance(source, first.point) - distance(source, second.point) ||
416
+ first.point.x - second.point.x ||
417
+ first.point.y - second.point.y,
418
+ )
419
+ }
420
+
421
+ function candidatesAreMutuallyClear(params: {
422
+ first: ViaSiteCandidate
423
+ second: ViaSiteCandidate
424
+ rules: DogboneViaSiteGeometryRules
425
+ }): boolean {
426
+ const { first, second, rules } = params
427
+ const canShareCopper =
428
+ rules.canShareCopper?.(first.connectionIndex, second.connectionIndex) ??
429
+ false
430
+ const requiredHoleSeparation = rules.viaHoleDiameter
431
+ ? rules.viaHoleDiameter + (rules.holeToHoleClearance ?? rules.clearance)
432
+ : 0
433
+ const requiredViaSeparation = canShareCopper
434
+ ? requiredHoleSeparation
435
+ : Math.max(rules.viaDiameter + rules.clearance, requiredHoleSeparation)
436
+ if (distance(first.point, second.point) < requiredViaSeparation - EPSILON) {
437
+ return false
438
+ }
439
+ const requiredViaToTraceClearance =
440
+ rules.viaDiameter / 2 + rules.traceWidth / 2 + rules.clearance
441
+ if (!canShareCopper) {
442
+ if (
443
+ distancePointToSegment(
444
+ first.point,
445
+ second.sourceSegment.start,
446
+ second.sourceSegment.end,
447
+ ) <
448
+ requiredViaToTraceClearance - EPSILON ||
449
+ distancePointToSegment(
450
+ second.point,
451
+ first.sourceSegment.start,
452
+ first.sourceSegment.end,
453
+ ) <
454
+ requiredViaToTraceClearance - EPSILON
455
+ ) {
456
+ return false
457
+ }
458
+ }
459
+ if (canShareCopper) return true
460
+ return segmentsAreClear(
461
+ first.sourceSegment,
462
+ second.sourceSegment,
463
+ rules.clearance,
464
+ )
465
+ }
466
+
467
+ function matchComponent(params: {
468
+ component: ComponentMatchingInput
469
+ rules: DogboneViaSiteGeometryRules
470
+ consumeSearchState: () => boolean
471
+ }): Map<number, Point2D> | null {
472
+ const { component, rules, consumeSearchState } = params
473
+ const entries: ConnectionCandidates[] = component.connections.map(
474
+ (connection) => ({
475
+ connection,
476
+ candidates: getConnectionCandidates({ connection, component, rules }),
477
+ }),
478
+ )
479
+ if (entries.some((entry) => entry.candidates.length === 0)) return null
480
+
481
+ const assignedCandidates = new Map<number, ViaSiteCandidate>()
482
+ const remaining = new Set(
483
+ entries.map((entry) => entry.connection.preparedConnection.connectionIndex),
484
+ )
485
+ const entryByConnectionIndex = new Map(
486
+ entries.map((entry) => [
487
+ entry.connection.preparedConnection.connectionIndex,
488
+ entry,
489
+ ]),
490
+ )
491
+
492
+ const getViableCandidates = (
493
+ entry: ConnectionCandidates,
494
+ ): ViaSiteCandidate[] =>
495
+ entry.candidates.filter((candidate) =>
496
+ [...assignedCandidates.values()].every((assignedCandidate) =>
497
+ candidatesAreMutuallyClear({
498
+ first: candidate,
499
+ second: assignedCandidate,
500
+ rules,
501
+ }),
502
+ ),
503
+ )
504
+
505
+ const augmentMatching = (): boolean => {
506
+ if (!consumeSearchState()) return false
507
+ if (remaining.size === 0) return true
508
+
509
+ let selectedEntry: ConnectionCandidates | undefined
510
+ let selectedCandidates: ViaSiteCandidate[] = []
511
+ for (const connectionIndex of [...remaining].toSorted(
512
+ (first, second) => first - second,
513
+ )) {
514
+ const entry = entryByConnectionIndex.get(connectionIndex)!
515
+ const viableCandidates = getViableCandidates(entry)
516
+ if (viableCandidates.length === 0) return false
517
+ if (
518
+ !selectedEntry ||
519
+ viableCandidates.length < selectedCandidates.length ||
520
+ (viableCandidates.length === selectedCandidates.length &&
521
+ connectionIndex <
522
+ selectedEntry.connection.preparedConnection.connectionIndex)
523
+ ) {
524
+ selectedEntry = entry
525
+ selectedCandidates = viableCandidates
526
+ }
527
+ }
528
+
529
+ const connectionIndex =
530
+ selectedEntry!.connection.preparedConnection.connectionIndex
531
+ remaining.delete(connectionIndex)
532
+ for (const candidate of selectedCandidates) {
533
+ assignedCandidates.set(connectionIndex, candidate)
534
+ if (augmentMatching()) return true
535
+ assignedCandidates.delete(connectionIndex)
536
+ }
537
+ remaining.add(connectionIndex)
538
+ return false
539
+ }
540
+
541
+ if (!augmentMatching()) return null
542
+ return new Map(
543
+ [...assignedCandidates.entries()]
544
+ .toSorted(([first], [second]) => first - second)
545
+ .map(([connectionIndex, candidate]) => [
546
+ connectionIndex,
547
+ { ...candidate.point },
548
+ ]),
549
+ )
550
+ }
551
+
552
+ /**
553
+ * Matches every prepared connection to a legal adjacent dogbone via site.
554
+ *
555
+ * Candidate sites are derived from the component pad-center grid: midpoint
556
+ * gaps plus one half-pitch perimeter coordinate on each side. The matcher
557
+ * never infers component or connection metadata from identifiers.
558
+ */
559
+ export function matchComponentDogboneViaSites(
560
+ preparedBuses: readonly PreparedBus[],
561
+ rules: DogboneViaSiteGeometryRules,
562
+ ): Map<number, Point2D> | null {
563
+ const maximumSearchStates = assertGeometryRules(rules)
564
+ if (preparedBuses.length === 0) return new Map()
565
+
566
+ let consumedSearchStates = 0
567
+ const consumeSearchState = (): boolean => {
568
+ consumedSearchStates++
569
+ return consumedSearchStates <= maximumSearchStates
570
+ }
571
+ const result = new Map<number, Point2D>()
572
+ for (const component of getComponentMatchingInputs(preparedBuses)) {
573
+ const componentResult = matchComponent({
574
+ component,
575
+ rules,
576
+ consumeSearchState,
577
+ })
578
+ if (!componentResult) return null
579
+ for (const [connectionIndex, point] of componentResult) {
580
+ result.set(connectionIndex, point)
581
+ }
582
+ }
583
+ return result
584
+ }