@tscircuit/fanout-solver 0.0.18 → 0.0.19

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 CHANGED
@@ -42,6 +42,11 @@ and treats each bus-layer decision atomically.
42
42
  alternate instead of favoring one axis; square grids distribute equally
43
43
  across north, south, east, and west.
44
44
  - Enumerates combinations of the copper layers implied by `layerCount`.
45
+ - Keeps a bounded beam of route alternatives for multi-connection buses, so
46
+ grouped power/signal lanes can backtrack across layer and track choices
47
+ before committing a prefix.
48
+ - Keys route-prefix caches by both bus and layer, preserving plan uniqueness
49
+ when grouped-layer search changes bus order.
45
50
  - Prefers depth-cycled layer assignments: matching north/south (or east/west)
46
51
  bus depths share a layer, and deeper pairs cycle through every available
47
52
  escape layer. This forces a small stackup to reuse routing channels.
@@ -61,9 +66,17 @@ and treats each bus-layer decision atomically.
61
66
  breakout corridor.
62
67
  - Chamfers orthogonal routing corners into 45° segments before validating and
63
68
  emitting the fanout.
64
- - Verifies pad, via, trace, and already-routed fanout clearance.
65
- - Treats an obstacle whose `connectedTo` list contains the connection name as
66
- electrically connected copper rather than a foreign keepout.
69
+ - Verifies oriented-pad, via, trace, and already-routed fanout clearance on
70
+ every complete candidate, independent of the routing strategy that produced
71
+ it.
72
+ - Resolves `netConnectionName`, connection, port, trace, and obstacle metadata
73
+ into electrical-net identities. Same-net copper may merge; different-net
74
+ pads, traces, and vias must retain clearance on every layer they occupy.
75
+ - `allowSameNetMerges` lets grouped branches such as VCC or GND reuse connected
76
+ copper instead of reserving artificial clearance from one another. It is
77
+ opt-in; different electrical nets remain hard obstacles.
78
+ - Audits route continuity, unique connection coverage, boundary exits, and
79
+ retained downstream endpoints before marking a solution complete.
67
80
  - Emits supplied fanout traces, via obstacles, and moved breakout endpoints in a
68
81
  new `SimpleRouteJson`. The returned problem is ready for a downstream
69
82
  autorouter to finish.
@@ -177,6 +190,8 @@ bus-layer combination search.
177
190
  - `busLayerAssignments`: the selected layer for every bus
178
191
  - `busDirections`: the direction shared by each bus
179
192
  - `attempts`: score and success metadata for every tried layer combination
193
+ - `validation`: the final geometry/connectivity report, including the number of
194
+ independently validated breakouts
180
195
 
181
196
  ## Dataset 01
182
197
 
@@ -198,15 +213,18 @@ parameters. Each sample has one shared boundary around all of its footprints,
198
213
  and component bounds come from the exact footprinter-generated copper pad
199
214
  extents.
200
215
 
201
- ## SRJ19 benchmark
216
+ ## SRJ29 benchmark
202
217
 
203
- The repository also loads all 200 samples from
204
- [`tscircuit/dataset-srj19`](https://github.com/tscircuit/dataset-srj19) as a
205
- pinned development dependency. The adapter keeps the complete obstacle field
206
- but selects only connections that touch the BGA, producing progressively larger
207
- fanout problems with opposite-side passive overlays. Every adapted problem uses
208
- the same six-layer stackup (`top`, `inner1` through `inner4`, and `bottom`) so
209
- benchmark improvements are directly comparable.
218
+ The repository loads all 200 samples from the derivative
219
+ [`tscircuit/dataset-srj29-bga-decoupling`](https://github.com/tscircuit/dataset-srj29-bga-decoupling)
220
+ as a pinned development dependency. The adapter keeps the complete obstacle
221
+ field, including opposite-layer capacitor pads and bodies. VCC and GND are
222
+ grouped onto opposite boundary corridors, while the capacitor pad remains the
223
+ downstream endpoint of every power connection; a local capacitor or plane via
224
+ alone cannot count as a solved BGA pin. Remaining edge signals are grouped by
225
+ direction. Every adapted problem uses the same six-layer stackup (`top`,
226
+ `inner1` through `inner4`, and `bottom`) so benchmark improvements are directly
227
+ comparable.
210
228
 
211
229
  Run the full benchmark with:
212
230
 
@@ -219,22 +237,23 @@ Use `--sample sample001`, `--limit 10`, or
219
237
  default and print progress as they finish. `--concurrency 8` runs isolated
220
238
  samples in parallel, and `--sample-timeout-seconds 600` prevents a difficult
221
239
  sample from blocking the remaining work. Each run writes the full ordered
222
- results to `benchmark-results/srj19.json` and
223
- `benchmark-results/srj19.md`. Partial solutions are reported as benchmark
224
- results instead of failing the command, making current completion rates a
225
- baseline for solver improvements. `bun run benchmark:srj19` is an alias for the
226
- same command.
227
-
228
- The `SRJ19 Benchmark` GitHub Actions workflow runs the complete dataset on a
240
+ results to `benchmark-results/srj29.json` and
241
+ `benchmark-results/srj29.md`. A row is marked solved only when every input
242
+ connection has a validated breakout, all downstream endpoints remain attached,
243
+ and the independent DRC audit passes. Partial solutions are reported as
244
+ benchmark results instead of failing the command, making current completion
245
+ rates a baseline for solver improvements. `bun run benchmark:srj29` is an alias
246
+ for the same command.
247
+
248
+ The `SRJ29 Benchmark` GitHub Actions workflow runs the complete dataset on a
229
249
  Blacksmith 32-vCPU ARM runner with 32 sample processes by default. It can be
230
250
  started manually with an optional sample id, or for a pull request by adding
231
251
  `[BENCHMARK TEST]` to its title. The workflow publishes the Markdown summary and
232
252
  uploads both reports as an artifact.
233
253
 
234
- Run `bun run start` and open the `datasets/srj19` Cosmos fixture to step the
235
- selected sample through `GenericSolverDebugger`. The page has Previous/Next,
236
- sample dropdown, and range controls and stores the selection in the `sample`
237
- URL parameter.
254
+ Run `bun run start` and inspect the SRJ29 fixtures to step through the selected
255
+ sample. The derivative dataset also publishes dedicated Cosmos pages for the
256
+ first ten samples.
238
257
 
239
258
  ## Dataset 02
240
259
 
@@ -18,8 +18,10 @@ import {
18
18
  } from "./route-bus"
19
19
  import { routeSingleLayerWithAdaptiveExits } from "./route-single-layer-adaptive-exits"
20
20
  import { routeSingleLayerWithPushAndShove } from "./route-single-layer-push-shove"
21
+ import { validateFanoutSolution } from "./validate-fanout-solution"
21
22
  import type {
22
23
  AssignmentAttempt,
24
+ Bounds,
23
25
  FanoutAttemptSummary,
24
26
  FanoutBorderDistribution,
25
27
  FanoutRoutePlan,
@@ -34,6 +36,7 @@ interface ResolvedFanoutConfig {
34
36
  viaHoleDiameter: number
35
37
  clearance: number
36
38
  compactBusTracks: boolean
39
+ allowSameNetMerges: boolean
37
40
  singleLayerPushAndShove: boolean
38
41
  singleLayerAdaptiveExits: boolean
39
42
  borderDistribution: FanoutBorderDistribution
@@ -123,6 +126,7 @@ function resolveConfig(
123
126
  viaHoleDiameter,
124
127
  clearance,
125
128
  compactBusTracks: options.compactBusTracks ?? false,
129
+ allowSameNetMerges: options.allowSameNetMerges ?? false,
126
130
  singleLayerPushAndShove: options.singleLayerPushAndShove ?? false,
127
131
  singleLayerAdaptiveExits: options.singleLayerAdaptiveExits ?? false,
128
132
  borderDistribution,
@@ -303,6 +307,7 @@ function getCandidateEscapeLayersForBus(params: {
303
307
  viaHoleDiameter: config.viaHoleDiameter,
304
308
  clearance: config.clearance,
305
309
  compactBusTracks: config.compactBusTracks,
310
+ allowSameNetMerges: config.allowSameNetMerges,
306
311
  staticClearanceCache,
307
312
  }) !== null,
308
313
  )
@@ -422,6 +427,35 @@ export class FanoutSolver extends BaseSolver {
422
427
  return "FanoutSolver"
423
428
  }
424
429
 
430
+ private getValidationBoundary(): Bounds {
431
+ if (this.options.sharedBoundary) return this.options.sharedBoundary
432
+ const firstBoundary = this.preparedBuses[0]?.sharedBoundary
433
+ if (!firstBoundary) return this.inputSrj.bounds
434
+ return this.preparedBuses.slice(1).reduce<Bounds>(
435
+ (boundary, bus) => ({
436
+ minX: Math.min(boundary.minX, bus.sharedBoundary.minX),
437
+ maxX: Math.max(boundary.maxX, bus.sharedBoundary.maxX),
438
+ minY: Math.min(boundary.minY, bus.sharedBoundary.minY),
439
+ maxY: Math.max(boundary.maxY, bus.sharedBoundary.maxY),
440
+ }),
441
+ { ...firstBoundary },
442
+ )
443
+ }
444
+
445
+ private validateCompletePlans(
446
+ plans: readonly FanoutRoutePlan[],
447
+ outputSrj: SimpleRouteJson,
448
+ ) {
449
+ return validateFanoutSolution({
450
+ inputSrj: this.inputSrj,
451
+ outputSrj,
452
+ plans,
453
+ preparedBuses: this.preparedBuses,
454
+ sharedBoundary: this.getValidationBoundary(),
455
+ clearance: this.config.clearance,
456
+ })
457
+ }
458
+
425
459
  private evaluateAssignmentWithStrategy(
426
460
  assignmentIndex: number,
427
461
  busLayerAssignments: Readonly<Record<string, string>>,
@@ -483,7 +517,12 @@ export class FanoutSolver extends BaseSolver {
483
517
  `FanoutSolver: assignment ${assignmentIndex} has no layer for bus "${bus.busId}"`,
484
518
  )
485
519
  }
486
- routingPrefixKey += `${targetLayer.length}:${targetLayer};`
520
+ // The routing order can change between assignments (notably for
521
+ // group-by-layer search). A layer-only key can therefore replay a
522
+ // prefix belonging to a different bus and duplicate or drop plans when
523
+ // buses contain multiple connections. Include the bus identity so the
524
+ // cache remains valid for grouped power/signal lanes.
525
+ routingPrefixKey += `${bus.busId.length}:${bus.busId};${targetLayer.length}:${targetLayer};`
487
526
  const cachedPrefix = this.routingPrefixCache.get(routingPrefixKey)
488
527
  if (cachedPrefix) {
489
528
  plans = [...cachedPrefix.plans]
@@ -503,6 +542,7 @@ export class FanoutSolver extends BaseSolver {
503
542
  viaHoleDiameter: this.config.viaHoleDiameter,
504
543
  clearance: this.config.clearance,
505
544
  compactBusTracks: this.config.compactBusTracks,
545
+ allowSameNetMerges: this.config.allowSameNetMerges,
506
546
  staticClearanceCache: this.routeStaticClearanceCache,
507
547
  blockingBusCounts: currentBusBlockingCounts,
508
548
  })
@@ -524,6 +564,30 @@ export class FanoutSolver extends BaseSolver {
524
564
  })
525
565
  }
526
566
 
567
+ let validationIssues: FanoutAttemptSummary["validationIssues"]
568
+ let outputSrj = buildOutputSimpleRouteJson({
569
+ inputSrj: this.inputSrj,
570
+ plans,
571
+ layerNames: this.config.layerNames,
572
+ })
573
+ const validation =
574
+ plans.length === this.inputSrj.connections.length
575
+ ? this.validateCompletePlans(plans, outputSrj)
576
+ : null
577
+ if (validation && !validation.valid) {
578
+ // Every route-producing strategy must pass the same final layer-aware,
579
+ // same-net-aware copper validation before it can be scored as complete.
580
+ validationIssues = validation.issues
581
+ plans = []
582
+ failedBusIds = this.preparedBuses.map((bus) => bus.busId)
583
+ blockingBusCounts.clear()
584
+ outputSrj = buildOutputSimpleRouteJson({
585
+ inputSrj: this.inputSrj,
586
+ plans,
587
+ layerNames: this.config.layerNames,
588
+ })
589
+ }
590
+
527
591
  const routedBusCount = this.preparedBuses.length - failedBusIds.length
528
592
  const routeLength = plans.reduce((total, plan) => total + plan.length, 0)
529
593
  const unroutedConnectionCount =
@@ -541,6 +605,7 @@ export class FanoutSolver extends BaseSolver {
541
605
  routedConnectionCount: plans.length,
542
606
  failedBusIds,
543
607
  score,
608
+ ...(validationIssues ? { validationIssues } : {}),
544
609
  }
545
610
 
546
611
  return {
@@ -549,11 +614,7 @@ export class FanoutSolver extends BaseSolver {
549
614
  blockingBusIds: [...blockingBusCounts.entries()]
550
615
  .toSorted(([, firstCount], [, secondCount]) => secondCount - firstCount)
551
616
  .map(([busId]) => busId),
552
- outputSrj: buildOutputSimpleRouteJson({
553
- inputSrj: this.inputSrj,
554
- plans,
555
- layerNames: this.config.layerNames,
556
- }),
617
+ outputSrj,
557
618
  }
558
619
  }
559
620
 
@@ -596,8 +657,10 @@ export class FanoutSolver extends BaseSolver {
596
657
  * Search layer assignments and track alternatives together. The regular
597
658
  * assignment loop commits to one route per bus before the next bus is
598
659
  * considered, so a locally-valid track can still starve a later bus. A
599
- * bounded beam keeps several grouped-layer route prefixes alive and is
600
- * especially useful for the many singleton buses in the SRJ19 samples.
660
+ * bounded beam keeps several grouped-layer route prefixes alive. It also
661
+ * evaluates multi-connection buses atomically, so one promising route for a
662
+ * power or signal lane cannot starve a later bus before the solver explores
663
+ * an alternate layer/track combination.
601
664
  */
602
665
  private evaluateGroupedBeam(
603
666
  assignmentIndex: number,
@@ -605,9 +668,12 @@ export class FanoutSolver extends BaseSolver {
605
668
  ): EvaluatedAssignment | null {
606
669
  if (this.config.escapeLayers.length < 2) return null
607
670
  if (this.preparedBuses.length > 56) return null
608
- if (this.preparedBuses.some((bus) => bus.connections.length !== 1)) {
609
- return null
610
- }
671
+ const totalConnections = this.inputSrj.connections.length
672
+ // Multi-pin alternatives grow with both the bus width and the number of
673
+ // layer prefixes. Keep the new search bounded on the small/medium grouped
674
+ // problems it can improve, then let the regular assignment/repair search
675
+ // handle the very large benchmark samples without starving them.
676
+ if (totalConnections > 64) return null
611
677
  if (new Set(this.preparedBuses.map((bus) => bus.componentId)).size !== 1) {
612
678
  return null
613
679
  }
@@ -635,8 +701,17 @@ export class FanoutSolver extends BaseSolver {
635
701
  )
636
702
  })
637
703
 
638
- const beamWidth = 128
639
- const alternativesPerLayer = 4
704
+ const isSmallProblem = totalConnections <= 24
705
+ const hasMultiConnectionBus = this.preparedBuses.some(
706
+ (bus) => bus.connections.length > 1,
707
+ )
708
+ // Preserve the broad track search for small singleton problems. Grouped
709
+ // power buses already branch across every candidate layer and make each
710
+ // route-alternative expansion combinatorial, so retain layer diversity but
711
+ // only one atomic route per layer for those buses.
712
+ const beamWidth = isSmallProblem ? 48 : totalConnections <= 32 ? 24 : 12
713
+ const alternativesPerLayer =
714
+ isSmallProblem && !hasMultiConnectionBus ? 4 : 1
640
715
  let states: GroupedBeamState[] = [{ assignment: {}, plans: [] }]
641
716
 
642
717
  const getStateScore = (state: GroupedBeamState): number => {
@@ -684,6 +759,7 @@ export class FanoutSolver extends BaseSolver {
684
759
  viaHoleDiameter: this.config.viaHoleDiameter,
685
760
  clearance: this.config.clearance,
686
761
  compactBusTracks: this.config.compactBusTracks,
762
+ allowSameNetMerges: this.config.allowSameNetMerges,
687
763
  staticClearanceCache: this.routeStaticClearanceCache,
688
764
  },
689
765
  alternativesPerLayer,
@@ -722,6 +798,17 @@ export class FanoutSolver extends BaseSolver {
722
798
 
723
799
  const bestState = states[0]
724
800
  if (!bestState) return null
801
+ const outputSrj = buildOutputSimpleRouteJson({
802
+ inputSrj: this.inputSrj,
803
+ plans: bestState.plans,
804
+ layerNames: this.config.layerNames,
805
+ })
806
+ if (
807
+ bestState.plans.length === this.inputSrj.connections.length &&
808
+ !this.validateCompletePlans(bestState.plans, outputSrj).valid
809
+ ) {
810
+ return null
811
+ }
725
812
  const score =
726
813
  bestState.plans.length === this.inputSrj.connections.length
727
814
  ? bestState.plans.reduce((total, plan) => total + plan.length, 0) +
@@ -742,11 +829,7 @@ export class FanoutSolver extends BaseSolver {
742
829
  summary,
743
830
  plans: bestState.plans,
744
831
  blockingBusIds: [],
745
- outputSrj: buildOutputSimpleRouteJson({
746
- inputSrj: this.inputSrj,
747
- plans: bestState.plans,
748
- layerNames: this.config.layerNames,
749
- }),
832
+ outputSrj,
750
833
  }
751
834
  }
752
835
 
@@ -958,6 +1041,15 @@ export class FanoutSolver extends BaseSolver {
958
1041
  "FanoutSolver: getOutput() called before a complete fanout was solved",
959
1042
  )
960
1043
  }
1044
+ const validation = this.validateCompletePlans(
1045
+ this.bestAttempt.plans,
1046
+ this.bestAttempt.outputSrj,
1047
+ )
1048
+ if (!validation.valid) {
1049
+ throw new Error(
1050
+ `FanoutSolver: completed output failed validation: ${validation.issues[0]?.message ?? "unknown validation error"}`,
1051
+ )
1052
+ }
961
1053
  return {
962
1054
  simpleRouteJson: this.bestAttempt.outputSrj,
963
1055
  fanoutTraces: this.bestAttempt.plans.map((plan) => plan.trace),
@@ -978,6 +1070,7 @@ export class FanoutSolver extends BaseSolver {
978
1070
  this.preparedBuses.map((bus) => [bus.busId, bus.direction]),
979
1071
  ),
980
1072
  attempts: [...this.attempts],
1073
+ validation,
981
1074
  }
982
1075
  }
983
1076
 
package/lib/geometry.ts CHANGED
@@ -3,12 +3,27 @@ import type { Point2D, RoutedSegment } from "./types"
3
3
 
4
4
  const EPSILON = 1e-9
5
5
 
6
- type ShapeAwareObstacle = Obstacle & { shape?: "circle" }
6
+ type ShapeAwareObstacle = Obstacle & {
7
+ shape?: "circle"
8
+ ccwRotationDegrees?: number
9
+ }
7
10
 
8
11
  function obstacleIsCircular(obstacle: Obstacle): boolean {
9
12
  return (obstacle as ShapeAwareObstacle).shape === "circle"
10
13
  }
11
14
 
15
+ function toObstacleLocalPoint(point: Point2D, obstacle: Obstacle): Point2D {
16
+ const rotationRadians =
17
+ (-((obstacle as ShapeAwareObstacle).ccwRotationDegrees ?? 0) * Math.PI) /
18
+ 180
19
+ const dx = point.x - obstacle.center.x
20
+ const dy = point.y - obstacle.center.y
21
+ return {
22
+ x: dx * Math.cos(rotationRadians) - dy * Math.sin(rotationRadians),
23
+ y: dx * Math.sin(rotationRadians) + dy * Math.cos(rotationRadians),
24
+ }
25
+ }
26
+
12
27
  export function distance(a: Point2D, b: Point2D): number {
13
28
  return Math.hypot(a.x - b.x, a.y - b.y)
14
29
  }
@@ -76,9 +91,10 @@ export function pointIsInsideObstacle(
76
91
  if (obstacleIsCircular(obstacle)) {
77
92
  return distance(point, obstacle.center) <= obstacle.width / 2 + tolerance
78
93
  }
94
+ const localPoint = toObstacleLocalPoint(point, obstacle)
79
95
  return (
80
- Math.abs(point.x - obstacle.center.x) <= obstacle.width / 2 + tolerance &&
81
- Math.abs(point.y - obstacle.center.y) <= obstacle.height / 2 + tolerance
96
+ Math.abs(localPoint.x) <= obstacle.width / 2 + tolerance &&
97
+ Math.abs(localPoint.y) <= obstacle.height / 2 + tolerance
82
98
  )
83
99
  }
84
100
 
@@ -89,14 +105,9 @@ export function distancePointToObstacle(
89
105
  if (obstacleIsCircular(obstacle)) {
90
106
  return Math.max(0, distance(point, obstacle.center) - obstacle.width / 2)
91
107
  }
92
- const dx = Math.max(
93
- Math.abs(point.x - obstacle.center.x) - obstacle.width / 2,
94
- 0,
95
- )
96
- const dy = Math.max(
97
- Math.abs(point.y - obstacle.center.y) - obstacle.height / 2,
98
- 0,
99
- )
108
+ const localPoint = toObstacleLocalPoint(point, obstacle)
109
+ const dx = Math.max(Math.abs(localPoint.x) - obstacle.width / 2, 0)
110
+ const dy = Math.max(Math.abs(localPoint.y) - obstacle.height / 2, 0)
100
111
  return Math.hypot(dx, dy)
101
112
  }
102
113
 
@@ -111,16 +122,24 @@ export function distanceSegmentToObstacle(
111
122
  obstacle.width / 2,
112
123
  )
113
124
  }
125
+ const localStart = toObstacleLocalPoint(segment.start, obstacle)
126
+ const localEnd = toObstacleLocalPoint(segment.end, obstacle)
127
+ if (
128
+ Math.abs(localStart.x) <= obstacle.width / 2 + EPSILON &&
129
+ Math.abs(localStart.y) <= obstacle.height / 2 + EPSILON
130
+ ) {
131
+ return 0
132
+ }
114
133
  if (
115
- pointIsInsideObstacle(segment.start, obstacle) ||
116
- pointIsInsideObstacle(segment.end, obstacle)
134
+ Math.abs(localEnd.x) <= obstacle.width / 2 + EPSILON &&
135
+ Math.abs(localEnd.y) <= obstacle.height / 2 + EPSILON
117
136
  ) {
118
137
  return 0
119
138
  }
120
- const minX = obstacle.center.x - obstacle.width / 2
121
- const maxX = obstacle.center.x + obstacle.width / 2
122
- const minY = obstacle.center.y - obstacle.height / 2
123
- const maxY = obstacle.center.y + obstacle.height / 2
139
+ const minX = -obstacle.width / 2
140
+ const maxX = obstacle.width / 2
141
+ const minY = -obstacle.height / 2
142
+ const maxY = obstacle.height / 2
124
143
  const corners = [
125
144
  { x: minX, y: minY },
126
145
  { x: maxX, y: minY },
@@ -133,8 +152,8 @@ export function distanceSegmentToObstacle(
133
152
  minimumDistance = Math.min(
134
153
  minimumDistance,
135
154
  distanceSegmentToSegment(
136
- segment.start,
137
- segment.end,
155
+ localStart,
156
+ localEnd,
138
157
  corners[index]!,
139
158
  corners[(index + 1) % corners.length]!,
140
159
  ),
package/lib/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { FanoutSolver } from "./fanout-solver"
2
2
  export { getCopperLayerColor } from "./layer-colors"
3
3
  export { getCopperLayerNames } from "./layer-names"
4
+ export { validateFanoutSolution } from "./validate-fanout-solution"
4
5
  export type {
5
6
  Bounds,
6
7
  FanoutAttemptSummary,
@@ -15,6 +16,10 @@ export type {
15
16
  FanoutDirection,
16
17
  FanoutEdge,
17
18
  FanoutPlaneTermination,
19
+ FanoutRoutePlan,
18
20
  FanoutSolverOptions,
19
21
  FanoutSolverOutput,
22
+ FanoutValidationIssue,
23
+ FanoutValidationReport,
24
+ PreparedBus,
20
25
  } from "./types"
@@ -0,0 +1,163 @@
1
+ import type {
2
+ Obstacle,
3
+ SimpleRouteConnection,
4
+ SimpleRouteJson,
5
+ } from "@tscircuit/capacity-autorouter"
6
+
7
+ interface ElectricalNetIdentity {
8
+ connectionNetKeys: Map<string, string>
9
+ tokenNetKeys: Map<string, Set<string>>
10
+ }
11
+
12
+ const identityCache = new WeakMap<SimpleRouteJson, ElectricalNetIdentity>()
13
+
14
+ export function getConnectionNetKey(connection: SimpleRouteConnection): string {
15
+ return (
16
+ connection.netConnectionName ??
17
+ connection.rootConnectionName ??
18
+ connection.name
19
+ )
20
+ }
21
+
22
+ function addTokenNet(
23
+ tokenNetKeys: Map<string, Set<string>>,
24
+ token: string | undefined,
25
+ netKey: string,
26
+ ): boolean {
27
+ if (!token) return false
28
+ const keys = tokenNetKeys.get(token) ?? new Set<string>()
29
+ const sizeBefore = keys.size
30
+ keys.add(netKey)
31
+ tokenNetKeys.set(token, keys)
32
+ return keys.size !== sizeBefore
33
+ }
34
+
35
+ function getKnownNetKeys(
36
+ tokenNetKeys: Map<string, Set<string>>,
37
+ tokens: readonly string[],
38
+ ): Set<string> {
39
+ const keys = new Set<string>()
40
+ for (const token of tokens) {
41
+ for (const key of tokenNetKeys.get(token) ?? []) keys.add(key)
42
+ }
43
+ return keys
44
+ }
45
+
46
+ function createElectricalNetIdentity(
47
+ srj: SimpleRouteJson,
48
+ ): ElectricalNetIdentity {
49
+ const connectionNetKeys = new Map<string, string>()
50
+ const tokenNetKeys = new Map<string, Set<string>>()
51
+
52
+ for (const connection of srj.connections) {
53
+ const netKey = getConnectionNetKey(connection)
54
+ connectionNetKeys.set(connection.name, netKey)
55
+ addTokenNet(tokenNetKeys, connection.name, netKey)
56
+ addTokenNet(tokenNetKeys, connection.rootConnectionName, netKey)
57
+ addTokenNet(tokenNetKeys, connection.netConnectionName, netKey)
58
+ for (const point of connection.pointsToConnect) {
59
+ addTokenNet(tokenNetKeys, point.pointId, netKey)
60
+ addTokenNet(tokenNetKeys, point.pcb_port_id, netKey)
61
+ }
62
+ }
63
+
64
+ for (const trace of srj.traces ?? []) {
65
+ const netKey = trace.connection_name
66
+ ? connectionNetKeys.get(trace.connection_name)
67
+ : undefined
68
+ if (!netKey) continue
69
+ addTokenNet(tokenNetKeys, trace.pcb_trace_id, netKey)
70
+ for (const token of trace.connectsTo ?? []) {
71
+ addTokenNet(tokenNetKeys, token, netKey)
72
+ }
73
+ }
74
+
75
+ // Obstacle metadata often contains both a connection id and a lower-level
76
+ // connectivity id. Propagate the known net across that metadata so another
77
+ // pad that only names the connectivity id is still recognized as same-net.
78
+ for (let pass = 0; pass < 2; pass++) {
79
+ let changed = false
80
+ for (const obstacle of srj.obstacles) {
81
+ const netKeys = getKnownNetKeys(tokenNetKeys, obstacle.connectedTo)
82
+ if (netKeys.size !== 1) continue
83
+ const netKey = [...netKeys][0]!
84
+ for (const token of obstacle.connectedTo) {
85
+ changed = addTokenNet(tokenNetKeys, token, netKey) || changed
86
+ }
87
+ }
88
+ if (!changed) break
89
+ }
90
+
91
+ const parentByNetKey = new Map<string, string>()
92
+ const findRoot = (netKey: string): string => {
93
+ const parent = parentByNetKey.get(netKey) ?? netKey
94
+ parentByNetKey.set(netKey, parent)
95
+ if (parent === netKey) return netKey
96
+ const root = findRoot(parent)
97
+ parentByNetKey.set(netKey, root)
98
+ return root
99
+ }
100
+ const union = (first: string, second: string): void => {
101
+ const firstRoot = findRoot(first)
102
+ const secondRoot = findRoot(second)
103
+ if (firstRoot !== secondRoot) parentByNetKey.set(secondRoot, firstRoot)
104
+ }
105
+ for (const netKeys of tokenNetKeys.values()) {
106
+ const [firstNetKey, ...otherNetKeys] = [...netKeys]
107
+ if (!firstNetKey) continue
108
+ for (const otherNetKey of otherNetKeys) union(firstNetKey, otherNetKey)
109
+ }
110
+ for (const connectedTokens of [
111
+ ...srj.obstacles.map((obstacle) => obstacle.connectedTo),
112
+ ...(srj.traces ?? []).map((trace) => trace.connectsTo ?? []),
113
+ ]) {
114
+ const [firstNetKey, ...otherNetKeys] = [
115
+ ...getKnownNetKeys(tokenNetKeys, connectedTokens),
116
+ ]
117
+ if (!firstNetKey) continue
118
+ for (const otherNetKey of otherNetKeys) union(firstNetKey, otherNetKey)
119
+ }
120
+ for (const [connectionName, netKey] of connectionNetKeys) {
121
+ connectionNetKeys.set(connectionName, findRoot(netKey))
122
+ }
123
+ for (const [token, netKeys] of tokenNetKeys) {
124
+ tokenNetKeys.set(
125
+ token,
126
+ new Set([...netKeys].map((netKey) => findRoot(netKey))),
127
+ )
128
+ }
129
+
130
+ return { connectionNetKeys, tokenNetKeys }
131
+ }
132
+
133
+ function getElectricalNetIdentity(srj: SimpleRouteJson): ElectricalNetIdentity {
134
+ const cached = identityCache.get(srj)
135
+ if (cached) return cached
136
+ const identity = createElectricalNetIdentity(srj)
137
+ identityCache.set(srj, identity)
138
+ return identity
139
+ }
140
+
141
+ export function connectionsShareElectricalNet(
142
+ srj: SimpleRouteJson,
143
+ firstConnectionName: string,
144
+ secondConnectionName: string,
145
+ ): boolean {
146
+ const identity = getElectricalNetIdentity(srj)
147
+ const firstNet = identity.connectionNetKeys.get(firstConnectionName)
148
+ const secondNet = identity.connectionNetKeys.get(secondConnectionName)
149
+ return firstNet !== undefined && firstNet === secondNet
150
+ }
151
+
152
+ export function obstacleSharesElectricalNet(
153
+ srj: SimpleRouteJson,
154
+ obstacle: Obstacle,
155
+ connectionName: string,
156
+ ): boolean {
157
+ const identity = getElectricalNetIdentity(srj)
158
+ const connectionNet = identity.connectionNetKeys.get(connectionName)
159
+ if (!connectionNet) return false
160
+ return obstacle.connectedTo.some((token) =>
161
+ identity.tokenNetKeys.get(token)?.has(connectionNet),
162
+ )
163
+ }