@tscircuit/fanout-solver 0.0.36 → 0.0.38

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
@@ -73,6 +73,10 @@ and treats each bus-layer decision atomically.
73
73
  visibly continuous pad connections.
74
74
  - Chamfers orthogonal routing corners into 45° segments before validating and
75
75
  emitting the fanout.
76
+ - Honors a boundary bus `maxLengthSkew` as a hard local-fanout constraint. It
77
+ adds straight/45° meanders only after the dense component escape, keeps the
78
+ original endpoints and vias, and atomically rejects an assignment when the
79
+ requested skew cannot fit inside that bus's shared boundary.
76
80
  - Verifies oriented-pad, via, trace, and already-routed fanout clearance on
77
81
  every complete candidate, independent of the routing strategy that produced
78
82
  it.
@@ -189,6 +193,7 @@ The canonical bus input is the current `SimpleRouteJson` bus structure:
189
193
  busId: "ddr",
190
194
  connectionNames: ["BUS_DDR_01", "BUS_DDR_02", "BUS_DDR_03"],
191
195
  preferredExit: "right",
196
+ maxLengthSkew: 0.25,
192
197
  },
193
198
  ],
194
199
  }
@@ -201,6 +206,13 @@ routed cleanly, the solver rejects that bus for the current layer assignment and
201
206
  tries another combination. `busExitPreferences` provides the same override
202
207
  without modifying the input object.
203
208
 
209
+ `maxLengthSkew` is measured in millimeters of planar routed copper within this
210
+ fanout phase. It is supported for multi-connection boundary buses. A loose or
211
+ omitted constraint leaves the routed geometry unchanged; an impossible
212
+ constraint fails instead of returning a fanout that violates the declared skew.
213
+ Plane-terminated buses reject `maxLengthSkew` because they do not have a
214
+ boundary tuning corridor.
215
+
204
216
  `availableCornersAndSides` is a solver-wide hard constraint. Its directed
205
217
  corner names distinguish the two edges meeting at a corner: `top_left` exits
206
218
  through the top edge, while `left_top` exits through the left edge. The complete
@@ -473,5 +485,7 @@ label.
473
485
  ## Scope
474
486
 
475
487
  This package owns the BGA pad-to-breakout prefix. It does not replace the
476
- board-level autorouter, length-match buses, or route arbitrary obstacles between
477
- the breakout boundary and the final destination.
488
+ board-level autorouter or route arbitrary obstacles between the breakout
489
+ boundary and the final destination. Its `maxLengthSkew` matching applies to the
490
+ local fanout prefix; end-to-end delay matching across multiple routing phases
491
+ still belongs to a board-level coordinator.
@@ -5,10 +5,9 @@ import type { FanoutRoutePlan, SimpleRouteJsonWithFanoutPlanes } from "./types"
5
5
  function createViaObstacle(
6
6
  plan: FanoutRoutePlan,
7
7
  layerNames: string[],
8
- endpoint = false,
8
+ via: NonNullable<FanoutRoutePlan["via"]>,
9
+ viaIndex: number | "endpoint",
9
10
  ): Obstacle | null {
10
- const via = endpoint ? plan.planeEndpointVia : plan.via
11
- if (!via) return null
12
11
  const zLayers = via.spanLayers.map((layer) => {
13
12
  const layerIndex = layerNames.indexOf(layer)
14
13
  if (layerIndex < 0) {
@@ -20,9 +19,12 @@ function createViaObstacle(
20
19
  })
21
20
  const outputIds = createFanoutOutputIds(plan)
22
21
  return {
23
- obstacleId: endpoint
24
- ? outputIds.planeEndpointViaObstacleId
25
- : outputIds.viaObstacleId,
22
+ obstacleId:
23
+ viaIndex === "endpoint"
24
+ ? outputIds.planeEndpointViaObstacleId
25
+ : viaIndex === 0
26
+ ? outputIds.viaObstacleId
27
+ : `${outputIds.viaObstacleId}:${viaIndex}`,
26
28
  type: "rect",
27
29
  center: via.center,
28
30
  width: via.diameter,
@@ -67,10 +69,30 @@ export function buildOutputSimpleRouteJson(params: {
67
69
  if (plan.termination.type === "plane") {
68
70
  planeTerminatedConnectionNames.add(plan.connectionName)
69
71
  }
70
- const viaObstacle = createViaObstacle(plan, layerNames)
71
- if (viaObstacle) viaObstacles.push(viaObstacle)
72
- const endpointViaObstacle = createViaObstacle(plan, layerNames, true)
73
- if (endpointViaObstacle) viaObstacles.push(endpointViaObstacle)
72
+ if (plan.via) {
73
+ const viaObstacle = createViaObstacle(plan, layerNames, plan.via, 0)
74
+ if (viaObstacle) viaObstacles.push(viaObstacle)
75
+ }
76
+ for (const [additionalViaIndex, via] of (
77
+ plan.additionalVias ?? []
78
+ ).entries()) {
79
+ const viaObstacle = createViaObstacle(
80
+ plan,
81
+ layerNames,
82
+ via,
83
+ additionalViaIndex + 1,
84
+ )
85
+ if (viaObstacle) viaObstacles.push(viaObstacle)
86
+ }
87
+ if (plan.planeEndpointVia) {
88
+ const endpointViaObstacle = createViaObstacle(
89
+ plan,
90
+ layerNames,
91
+ plan.planeEndpointVia,
92
+ "endpoint",
93
+ )
94
+ if (endpointViaObstacle) viaObstacles.push(endpointViaObstacle)
95
+ }
74
96
  }
75
97
  const planTraces = plans.flatMap((plan) => [
76
98
  plan.trace,
@@ -8,6 +8,7 @@ import {
8
8
  completeOriginalEndpoints,
9
9
  } from "./complete-original-endpoints"
10
10
  import { generateLayerAssignments, getCopperLayerNames } from "./layer-names"
11
+ import { matchBusPlanLengths } from "./match-bus-lengths"
11
12
  import {
12
13
  prepareFanoutBuses,
13
14
  resolveAvailableBoundaryRegions,
@@ -27,6 +28,7 @@ import type {
27
28
  FanoutRoutePlan,
28
29
  FanoutSolverOptions,
29
30
  FanoutSolverOutput,
31
+ FanoutValidationIssue,
30
32
  PreparedBus,
31
33
  } from "./types"
32
34
  import { validateFanoutSolution } from "./validate-fanout-solution"
@@ -219,6 +221,7 @@ function getPlanViaCount(plans: readonly FanoutRoutePlan[]): number {
219
221
  (count, plan) =>
220
222
  count +
221
223
  Number(Boolean(plan.via)) +
224
+ (plan.additionalVias?.length ?? 0) +
222
225
  Number(Boolean(plan.planeEndpointVia)),
223
226
  0,
224
227
  )
@@ -257,6 +260,27 @@ function busUsesDestinationGuidedTracks(bus: PreparedBus): boolean {
257
260
  })
258
261
  }
259
262
 
263
+ function getCommonExplicitExitTargetLayer(
264
+ bus: PreparedBus,
265
+ ): string | undefined {
266
+ if (
267
+ bus.connections.length === 0 ||
268
+ bus.connections.some(
269
+ (connection) =>
270
+ !connection.hasExplicitLayeredExitTarget ||
271
+ !connection.exitTargetPoint?.layer,
272
+ )
273
+ ) {
274
+ return undefined
275
+ }
276
+ const targetLayers = new Set(
277
+ bus.connections.map((connection) => connection.exitTargetPoint!.layer!),
278
+ )
279
+ if (targetLayers.size !== 1) return undefined
280
+ const [targetLayer] = targetLayers
281
+ return targetLayer
282
+ }
283
+
260
284
  function busIsOnOutwardComponentEdge(bus: PreparedBus): boolean {
261
285
  const isHorizontal = bus.direction === "left" || bus.direction === "right"
262
286
  const directionalCoordinates = isHorizontal
@@ -327,7 +351,14 @@ function createInitialLayerAssignment(params: {
327
351
  const viaLayers = routableEscapeLayers.filter(
328
352
  (layer) => layer !== sourceLayer,
329
353
  )
354
+ const commonExitTargetLayer = getCommonExplicitExitTargetLayer(bus)
330
355
  if (
356
+ commonExitTargetLayer &&
357
+ routableEscapeLayers.includes(commonExitTargetLayer)
358
+ ) {
359
+ assignment[bus.busId] = commonExitTargetLayer
360
+ } else if (
361
+ !busUsesCoordinatedWinding(bus) &&
331
362
  routableEscapeLayers.includes(sourceLayer) &&
332
363
  (busUsesDestinationGuidedTracks(bus) || busIsOnOutwardComponentEdge(bus))
333
364
  ) {
@@ -368,6 +399,17 @@ function prioritizeLayerAssignment(params: {
368
399
  ].slice(0, maxAssignments)
369
400
  }
370
401
 
402
+ function busUsesCoordinatedWinding(bus: PreparedBus): boolean {
403
+ return Boolean(
404
+ bus.exitEdge &&
405
+ bus.termination.type === "boundary" &&
406
+ bus.connections.length > 0 &&
407
+ bus.connections.every(
408
+ (connection) => connection.hasExplicitLayeredExitTarget === true,
409
+ ),
410
+ )
411
+ }
412
+
371
413
  function getCandidateEscapeLayersForBus(params: {
372
414
  bus: PreparedBus
373
415
  srj: SimpleRouteJson
@@ -380,6 +422,10 @@ function getCandidateEscapeLayersForBus(params: {
380
422
  busAllowedLayers === undefined
381
423
  ? config.escapeLayers
382
424
  : config.escapeLayers.filter((layer) => busAllowedLayers.includes(layer))
425
+ // A coordinated winding route is deliberately planned with the other buses'
426
+ // committed escape vias present. Testing it in isolation is both expensive
427
+ // and can reject a layer whose shared via field guides a valid bus ordering.
428
+ if (busUsesCoordinatedWinding(bus)) return allowedEscapeLayers
383
429
  const individuallyRoutableLayers = allowedEscapeLayers.filter(
384
430
  (targetLayer) =>
385
431
  routeBus({
@@ -438,6 +484,7 @@ export class FanoutSolver extends BaseSolver {
438
484
  private nextAssignmentIndex = 0
439
485
  private nextGeneratedAssignmentIndex = 0
440
486
  private bestAttempt: AssignmentAttempt | null = null
487
+ private lengthMatchingFailure: FanoutValidationIssue | null = null
441
488
  private endpointCompletion: CompleteOriginalEndpointsResult | null = null
442
489
 
443
490
  constructor(
@@ -453,6 +500,19 @@ export class FanoutSolver extends BaseSolver {
453
500
  this.preparedBuses = prepareFanoutBuses(this.routingSrj, options)
454
501
  validateCornerBandCapacities(this.preparedBuses, this.config)
455
502
  for (const bus of this.preparedBuses) {
503
+ for (const connection of bus.connections) {
504
+ if (!connection.hasExplicitLayeredExitTarget) continue
505
+ const targetLayer = connection.exitTargetPoint?.layer
506
+ if (
507
+ typeof targetLayer !== "string" ||
508
+ targetLayer.length === 0 ||
509
+ !this.config.layerNames.includes(targetLayer)
510
+ ) {
511
+ throw new Error(
512
+ `FanoutSolver: connection exit target for "${connection.connection.name}" uses unavailable layer "${String(targetLayer)}"`,
513
+ )
514
+ }
515
+ }
456
516
  for (const allowedLayer of bus.allowedLayers ?? []) {
457
517
  if (!this.config.layerNames.includes(allowedLayer)) {
458
518
  throw new Error(
@@ -471,6 +531,9 @@ export class FanoutSolver extends BaseSolver {
471
531
  `FanoutSolver: bus "${bus.busId}" has no allowed layer in escapeLayers`,
472
532
  )
473
533
  }
534
+ bus.routableEscapeLayers = this.config.escapeLayers.filter(
535
+ (layer) => bus.allowedLayers?.includes(layer) ?? true,
536
+ )
474
537
  if (bus.termination.type !== "plane") continue
475
538
  const planeLayer = bus.termination.layer
476
539
  if (!this.config.layerNames.includes(planeLayer)) {
@@ -598,6 +661,19 @@ export class FanoutSolver extends BaseSolver {
598
661
  })
599
662
  }
600
663
 
664
+ private matchCompletePlanLengths(
665
+ plans: readonly FanoutRoutePlan[],
666
+ ): ReturnType<typeof matchBusPlanLengths> {
667
+ return matchBusPlanLengths({
668
+ plans,
669
+ preparedBuses: this.preparedBuses,
670
+ inputSrj: this.inputSrj,
671
+ sharedBoundary: this.getValidationBoundary(),
672
+ clearance: this.config.clearance,
673
+ allowSameNetMerges: this.config.allowSameNetMerges,
674
+ })
675
+ }
676
+
601
677
  private evaluateAssignmentWithStrategy(
602
678
  assignmentIndex: number,
603
679
  busLayerAssignments: Readonly<Record<string, string>>,
@@ -637,10 +713,22 @@ export class FanoutSolver extends BaseSolver {
637
713
  failedBusIds.push(...this.preparedBuses.map((bus) => bus.busId))
638
714
  }
639
715
  }
640
- const busesInRoutingOrder = [...this.preparedBuses].sort(
641
- (a, b) =>
716
+ const busesInRoutingOrder = [...this.preparedBuses].sort((a, b) => {
717
+ const aUsesCoordinatedWinding = busUsesCoordinatedWinding(a)
718
+ const bUsesCoordinatedWinding = busUsesCoordinatedWinding(b)
719
+ const aLayerIndex = this.config.layerNames.indexOf(
720
+ busLayerAssignments[a.busId] ?? "",
721
+ )
722
+ const bLayerIndex = this.config.layerNames.indexOf(
723
+ busLayerAssignments[b.busId] ?? "",
724
+ )
725
+ return (
642
726
  Number(b.termination.type === "plane") -
643
727
  Number(a.termination.type === "plane") ||
728
+ Number(bUsesCoordinatedWinding) - Number(aUsesCoordinatedWinding) ||
729
+ (aUsesCoordinatedWinding && bUsesCoordinatedWinding
730
+ ? bLayerIndex - aLayerIndex
731
+ : 0) ||
644
732
  (routingStrategy === "group-by-layer"
645
733
  ? (busLayerAssignments[a.busId] ?? "").localeCompare(
646
734
  busLayerAssignments[b.busId] ?? "",
@@ -652,8 +740,9 @@ export class FanoutSolver extends BaseSolver {
652
740
  : b.connections.length - a.connections.length ||
653
741
  (routingStrategy === "deep-first"
654
742
  ? getBusDistanceToBoundary(b) - getBusDistanceToBoundary(a)
655
- : getBusDistanceToBoundary(a) - getBusDistanceToBoundary(b))),
656
- )
743
+ : getBusDistanceToBoundary(a) - getBusDistanceToBoundary(b)))
744
+ )
745
+ })
657
746
 
658
747
  let routingPrefixKey = `${routingStrategy}|`
659
748
  for (const bus of useSingleLayerPushAndShove ? [] : busesInRoutingOrder) {
@@ -711,6 +800,29 @@ export class FanoutSolver extends BaseSolver {
711
800
  }
712
801
 
713
802
  let validationIssues: FanoutAttemptSummary["validationIssues"]
803
+ if (plans.length === this.inputSrj.connections.length) {
804
+ const lengthMatching = this.matchCompletePlanLengths(plans)
805
+ if (lengthMatching.plans) {
806
+ plans = lengthMatching.plans
807
+ } else {
808
+ const constrainedBus = lengthMatching.failedBus
809
+ const lengthMatchingIssue: FanoutValidationIssue = {
810
+ code: "bus-length-skew",
811
+ message: `Bus ${constrainedBus.busId} could not satisfy its ${constrainedBus.maxLengthSkew!.toFixed(6)}mm routed-length skew within the fanout boundary`,
812
+ busId: constrainedBus.busId,
813
+ }
814
+ validationIssues = [lengthMatchingIssue]
815
+ this.lengthMatchingFailure ??= lengthMatchingIssue
816
+ plans = []
817
+ failedBusIds = [
818
+ constrainedBus.busId,
819
+ ...this.preparedBuses
820
+ .map((bus) => bus.busId)
821
+ .filter((busId) => busId !== constrainedBus.busId),
822
+ ]
823
+ blockingBusCounts.clear()
824
+ }
825
+ }
714
826
  let outputSrj = buildOutputSimpleRouteJson({
715
827
  inputSrj: this.inputSrj,
716
828
  plans,
@@ -780,7 +892,8 @@ export class FanoutSolver extends BaseSolver {
780
892
  )
781
893
  if (
782
894
  bestAttempt.summary.routedConnectionCount ===
783
- this.inputSrj.connections.length
895
+ this.inputSrj.connections.length &&
896
+ this.getCoordinatedAdditionalViaCount(bestAttempt.plans) === 0
784
897
  ) {
785
898
  return bestAttempt
786
899
  }
@@ -791,12 +904,13 @@ export class FanoutSolver extends BaseSolver {
791
904
  busLayerAssignments,
792
905
  routingStrategy,
793
906
  )
794
- if (attempt.summary.score < bestAttempt.summary.score) {
907
+ if (this.isAttemptBetter(attempt, bestAttempt)) {
795
908
  bestAttempt = attempt
796
909
  }
797
910
  if (
798
911
  bestAttempt.summary.routedConnectionCount ===
799
- this.inputSrj.connections.length
912
+ this.inputSrj.connections.length &&
913
+ this.getCoordinatedAdditionalViaCount(bestAttempt.plans) === 0
800
914
  ) {
801
915
  return bestAttempt
802
916
  }
@@ -829,7 +943,24 @@ export class FanoutSolver extends BaseSolver {
829
943
  return null
830
944
  }
831
945
 
946
+ const getMaximumViaSpan = (bus: PreparedBus): number => {
947
+ const sourceLayerIndex = this.config.layerNames.indexOf(
948
+ bus.connections[0]?.sourceLayer ?? "",
949
+ )
950
+ const candidateLayers =
951
+ bus.termination.type === "plane"
952
+ ? [bus.termination.layer]
953
+ : (this.escapeLayersByBusId[bus.busId] ?? this.config.escapeLayers)
954
+ return Math.max(
955
+ 0,
956
+ ...candidateLayers.map((layer) =>
957
+ Math.abs(this.config.layerNames.indexOf(layer) - sourceLayerIndex),
958
+ ),
959
+ )
960
+ }
832
961
  const busesInSearchOrder = [...this.preparedBuses].sort((a, b) => {
962
+ const aUsesCoordinatedWinding = busUsesCoordinatedWinding(a)
963
+ const bUsesCoordinatedWinding = busUsesCoordinatedWinding(b)
833
964
  const aLayerCount =
834
965
  a.termination.type === "plane"
835
966
  ? 1
@@ -843,6 +974,10 @@ export class FanoutSolver extends BaseSolver {
843
974
  return (
844
975
  Number(b.termination.type === "plane") -
845
976
  Number(a.termination.type === "plane") ||
977
+ Number(bUsesCoordinatedWinding) - Number(aUsesCoordinatedWinding) ||
978
+ (aUsesCoordinatedWinding && bUsesCoordinatedWinding
979
+ ? getMaximumViaSpan(b) - getMaximumViaSpan(a)
980
+ : 0) ||
846
981
  (groupByDirection ? a.direction.localeCompare(b.direction) : 0) ||
847
982
  aLayerCount - bLayerCount ||
848
983
  b.componentObstacles.length - a.componentObstacles.length ||
@@ -880,7 +1015,9 @@ export class FanoutSolver extends BaseSolver {
880
1015
  return count
881
1016
  }
882
1017
  const sourceLayer = bus.connections[0]?.sourceLayer
883
- return state.assignment[bus.busId] === sourceLayer
1018
+ const preferredLayer =
1019
+ getCommonExplicitExitTargetLayer(bus) ?? sourceLayer
1020
+ return state.assignment[bus.busId] === preferredLayer
884
1021
  ? count
885
1022
  : count + bus.connections.length
886
1023
  },
@@ -920,10 +1057,13 @@ export class FanoutSolver extends BaseSolver {
920
1057
  )
921
1058
  }
922
1059
  const sourceLayer = bus.connections[0]?.sourceLayer
1060
+ const commonExitTargetLayer = getCommonExplicitExitTargetLayer(bus)
923
1061
  const preferSourceLayer = busUsesDestinationGuidedTracks(bus)
924
1062
  const orderedLayers = candidateLayers.toSorted(
925
1063
  (first, second) =>
926
1064
  (layerLoads.get(first) ?? 0) - (layerLoads.get(second) ?? 0) ||
1065
+ Number(second === commonExitTargetLayer) -
1066
+ Number(first === commonExitTargetLayer) ||
927
1067
  (preferSourceLayer
928
1068
  ? Number(second === sourceLayer) - Number(first === sourceLayer)
929
1069
  : Number(first === sourceLayer) -
@@ -963,6 +1103,10 @@ export class FanoutSolver extends BaseSolver {
963
1103
 
964
1104
  if (nextStates.length === 0) return null
965
1105
  nextStates.sort((first, second) => {
1106
+ const additionalViaDifference =
1107
+ this.getCoordinatedAdditionalViaCount(first.plans) -
1108
+ this.getCoordinatedAdditionalViaCount(second.plans)
1109
+ if (additionalViaDifference !== 0) return additionalViaDifference
966
1110
  const scoreDifference = getStateScore(first) - getStateScore(second)
967
1111
  if (Math.abs(scoreDifference) > 1e-9) return scoreDifference
968
1112
  return JSON.stringify(first.assignment).localeCompare(
@@ -981,30 +1125,56 @@ export class FanoutSolver extends BaseSolver {
981
1125
  }
982
1126
  }
983
1127
 
984
- const bestState = states[0]
985
- if (!bestState) return null
986
- const outputSrj = buildOutputSimpleRouteJson({
987
- inputSrj: this.inputSrj,
988
- plans: bestState.plans,
989
- layerNames: this.config.layerNames,
990
- })
991
- if (
992
- bestState.plans.length === this.inputSrj.connections.length &&
993
- !this.validateCompletePlans(bestState.plans, outputSrj).valid
994
- ) {
995
- return null
1128
+ let bestState: GroupedBeamState | undefined
1129
+ let outputSrj: SimpleRouteJson | undefined
1130
+ let bestMatchedScore = Number.POSITIVE_INFINITY
1131
+ let bestAdditionalViaCount = Number.POSITIVE_INFINITY
1132
+ const getCompleteStateScore = (state: GroupedBeamState): number =>
1133
+ state.plans.reduce((total, plan) => total + plan.length, 0) +
1134
+ getPlanViaCount(state.plans) * 0.1 +
1135
+ assignmentLoadPenalty(
1136
+ state.assignment,
1137
+ this.preparedBuses,
1138
+ this.config.balanceLayerLoadByConnectionCount,
1139
+ ) *
1140
+ getLayerLoadPenaltyWeight(this.config)
1141
+ const hasLengthConstraints = this.preparedBuses.some(
1142
+ (bus) => bus.maxLengthSkew !== undefined,
1143
+ )
1144
+ for (const state of states) {
1145
+ if (state.plans.length !== this.inputSrj.connections.length) continue
1146
+ const lengthMatching = this.matchCompletePlanLengths(state.plans)
1147
+ if (!lengthMatching.plans) continue
1148
+ const lengthMatchedPlans = lengthMatching.plans
1149
+ const candidateOutput = buildOutputSimpleRouteJson({
1150
+ inputSrj: this.inputSrj,
1151
+ plans: lengthMatchedPlans,
1152
+ layerNames: this.config.layerNames,
1153
+ })
1154
+ if (
1155
+ !this.validateCompletePlans(lengthMatchedPlans, candidateOutput).valid
1156
+ ) {
1157
+ continue
1158
+ }
1159
+ const candidateState = { ...state, plans: lengthMatchedPlans }
1160
+ const candidateAdditionalViaCount =
1161
+ this.getCoordinatedAdditionalViaCount(lengthMatchedPlans)
1162
+ const candidateScore = getCompleteStateScore(candidateState)
1163
+ if (
1164
+ !bestState ||
1165
+ candidateAdditionalViaCount < bestAdditionalViaCount ||
1166
+ (candidateAdditionalViaCount === bestAdditionalViaCount &&
1167
+ candidateScore < bestMatchedScore)
1168
+ ) {
1169
+ bestState = candidateState
1170
+ outputSrj = candidateOutput
1171
+ bestMatchedScore = candidateScore
1172
+ bestAdditionalViaCount = candidateAdditionalViaCount
1173
+ }
1174
+ if (!hasLengthConstraints) break
996
1175
  }
997
- const score =
998
- bestState.plans.length === this.inputSrj.connections.length
999
- ? bestState.plans.reduce((total, plan) => total + plan.length, 0) +
1000
- getPlanViaCount(bestState.plans) * 0.1 +
1001
- assignmentLoadPenalty(
1002
- bestState.assignment,
1003
- this.preparedBuses,
1004
- this.config.balanceLayerLoadByConnectionCount,
1005
- ) *
1006
- getLayerLoadPenaltyWeight(this.config)
1007
- : Number.POSITIVE_INFINITY
1176
+ if (!bestState || !outputSrj) return null
1177
+ const score = bestMatchedScore
1008
1178
  if (!Number.isFinite(score)) return null
1009
1179
 
1010
1180
  const summary: FanoutAttemptSummary = {
@@ -1116,6 +1286,66 @@ export class FanoutSolver extends BaseSolver {
1116
1286
  )
1117
1287
  }
1118
1288
 
1289
+ private getCoordinatedAdditionalViaCount(
1290
+ plans: readonly FanoutRoutePlan[],
1291
+ ): number {
1292
+ const coordinatedBusIds = new Set(
1293
+ this.preparedBuses
1294
+ .filter(busUsesCoordinatedWinding)
1295
+ .map((bus) => bus.busId),
1296
+ )
1297
+ return plans.reduce(
1298
+ (count, plan) =>
1299
+ count +
1300
+ (coordinatedBusIds.has(plan.busId)
1301
+ ? (plan.additionalVias?.length ?? 0)
1302
+ : 0),
1303
+ 0,
1304
+ )
1305
+ }
1306
+
1307
+ private isAttemptBetter(
1308
+ candidate: AssignmentAttempt,
1309
+ current: AssignmentAttempt,
1310
+ ): boolean {
1311
+ if (
1312
+ candidate.summary.routedConnectionCount !==
1313
+ current.summary.routedConnectionCount
1314
+ ) {
1315
+ return (
1316
+ candidate.summary.routedConnectionCount >
1317
+ current.summary.routedConnectionCount
1318
+ )
1319
+ }
1320
+ if (candidate.summary.routedBusCount !== current.summary.routedBusCount) {
1321
+ return candidate.summary.routedBusCount > current.summary.routedBusCount
1322
+ }
1323
+ const candidateAdditionalVias = this.getCoordinatedAdditionalViaCount(
1324
+ candidate.plans,
1325
+ )
1326
+ const currentAdditionalVias = this.getCoordinatedAdditionalViaCount(
1327
+ current.plans,
1328
+ )
1329
+ if (candidateAdditionalVias !== currentAdditionalVias) {
1330
+ return candidateAdditionalVias < currentAdditionalVias
1331
+ }
1332
+ return candidate.summary.score < current.summary.score
1333
+ }
1334
+
1335
+ private hasGloballyViaMinimalBestAttempt(): boolean {
1336
+ if (!this.hasCompleteBestAttempt() || !this.bestAttempt) return false
1337
+ if (
1338
+ this.preparedBuses.length === 0 ||
1339
+ !this.preparedBuses.every(busUsesCoordinatedWinding)
1340
+ ) {
1341
+ return false
1342
+ }
1343
+ return this.bestAttempt.plans.every(
1344
+ (plan) =>
1345
+ plan.via !== undefined && (plan.additionalVias?.length ?? 0) === 0,
1346
+ )
1347
+ }
1348
+
1119
1349
  private shouldEvaluateGroupedBeam(): boolean {
1120
1350
  if (this.groupedBeamEvaluated || this.nextAssignmentIndex === 0) {
1121
1351
  return false
@@ -1130,6 +1360,14 @@ export class FanoutSolver extends BaseSolver {
1130
1360
  }
1131
1361
 
1132
1362
  override _step(): void {
1363
+ if (
1364
+ this.nextAssignmentIndex > 0 &&
1365
+ this.hasGloballyViaMinimalBestAttempt()
1366
+ ) {
1367
+ this.completeBestAttemptEndpoints()
1368
+ this.solved = true
1369
+ return
1370
+ }
1133
1371
  // Try the deterministic assignment and only its targeted repair queue
1134
1372
  // before paying for the grouped beam. If the beam cannot solve, continue
1135
1373
  // with the broader generated-assignment search below.
@@ -1143,7 +1381,7 @@ export class FanoutSolver extends BaseSolver {
1143
1381
  this.attempts.push(beamAttempt.summary)
1144
1382
  if (
1145
1383
  !this.bestAttempt ||
1146
- beamAttempt.summary.score < this.bestAttempt.summary.score
1384
+ this.isAttemptBetter(beamAttempt, this.bestAttempt)
1147
1385
  ) {
1148
1386
  this.bestAttempt = beamAttempt
1149
1387
  }
@@ -1159,11 +1397,19 @@ export class FanoutSolver extends BaseSolver {
1159
1397
  failedBuses: "none",
1160
1398
  bestScore: bestSummary.score,
1161
1399
  }
1162
- this.completeBestAttemptEndpoints()
1163
- this.solved = true
1164
- return
1400
+ if (
1401
+ this.getCoordinatedAdditionalViaCount(this.bestAttempt.plans) === 0
1402
+ ) {
1403
+ this.completeBestAttemptEndpoints()
1404
+ this.solved = true
1405
+ return
1406
+ }
1165
1407
  }
1166
- if (this.hasCompleteBestAttempt()) {
1408
+ if (
1409
+ this.hasCompleteBestAttempt() &&
1410
+ this.bestAttempt &&
1411
+ this.getCoordinatedAdditionalViaCount(this.bestAttempt.plans) === 0
1412
+ ) {
1167
1413
  this.completeBestAttemptEndpoints()
1168
1414
  this.solved = true
1169
1415
  return
@@ -1212,9 +1458,14 @@ export class FanoutSolver extends BaseSolver {
1212
1458
  this.solved = true
1213
1459
  } else {
1214
1460
  this.failed = true
1215
- this.error = this.bestAttempt
1216
- ? `FanoutSolver: best layer assignment routed ${this.bestAttempt.summary.routedConnectionCount}/${this.inputSrj.connections.length} connections`
1217
- : "FanoutSolver: no layer assignment could be evaluated"
1461
+ const validationMessage =
1462
+ this.lengthMatchingFailure?.message ??
1463
+ this.bestAttempt?.summary.validationIssues?.[0]?.message
1464
+ this.error = validationMessage
1465
+ ? `FanoutSolver: ${validationMessage}`
1466
+ : this.bestAttempt
1467
+ ? `FanoutSolver: best layer assignment routed ${this.bestAttempt.summary.routedConnectionCount}/${this.inputSrj.connections.length} connections`
1468
+ : "FanoutSolver: no layer assignment could be evaluated"
1218
1469
  }
1219
1470
  return
1220
1471
  }
@@ -1237,10 +1488,7 @@ export class FanoutSolver extends BaseSolver {
1237
1488
  )
1238
1489
  }
1239
1490
  this.attempts.push(attempt.summary)
1240
- if (
1241
- !this.bestAttempt ||
1242
- attempt.summary.score < this.bestAttempt.summary.score
1243
- ) {
1491
+ if (!this.bestAttempt || this.isAttemptBetter(attempt, this.bestAttempt)) {
1244
1492
  this.bestAttempt = attempt
1245
1493
  }
1246
1494
  this.stats = {
@@ -1253,7 +1501,9 @@ export class FanoutSolver extends BaseSolver {
1253
1501
  }
1254
1502
  if (
1255
1503
  this.groupedBeamEvaluated &&
1256
- attempt.summary.routedConnectionCount === this.inputSrj.connections.length
1504
+ attempt.summary.routedConnectionCount ===
1505
+ this.inputSrj.connections.length &&
1506
+ this.getCoordinatedAdditionalViaCount(this.bestAttempt.plans) === 0
1257
1507
  ) {
1258
1508
  this.completeBestAttemptEndpoints()
1259
1509
  this.solved = true