@tscircuit/fanout-solver 0.0.38 → 0.0.40

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