@tscircuit/fanout-solver 0.0.62 → 0.0.64

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.
@@ -1551,6 +1551,11 @@ export class FanoutSolver extends BaseSolver {
1551
1551
  promotedPlaneReservationBusIds?: readonly string[]
1552
1552
  preferredBoundaryViaPoints?: ReadonlyMap<number, { x: number; y: number }>
1553
1553
  planeReservationRetryCount?: number
1554
+ boundaryRecovery?: {
1555
+ busId: string
1556
+ preferOutward: boolean
1557
+ perpendicularSide: -1 | 1
1558
+ }
1554
1559
  }): Generator<FanoutWorkYield, MixedTerminationState | null, unknown> {
1555
1560
  if (this.config.allowBlindAndBuriedVias) return null
1556
1561
  // An outside-package singleton escape can depend on the completed signal
@@ -1601,12 +1606,16 @@ export class FanoutSolver extends BaseSolver {
1601
1606
  )
1602
1607
  const useConfiguredDensePlaneRouting =
1603
1608
  configuredDensePlaneRouting || useAdaptiveDensePlaneRouting
1609
+ const useBoundaryRecovery = params.boundaryRecovery !== undefined
1610
+ const useJointPlaneRepair =
1611
+ useConfiguredDensePlaneRouting || useBoundaryRecovery
1604
1612
  // A completed boundary assignment remains worth repairing jointly after
1605
1613
  // plane reservations change; a greedy refill can discard that assignment.
1606
- const useAdaptiveJointPlaneSelection =
1607
- useAdaptiveDensePlaneRouting &&
1608
- ((params.planeReservationRetryCount ?? 0) === 0 ||
1609
- Boolean(params.preferredBoundaryViaPoints))
1614
+ const useJointPlaneSelection =
1615
+ useBoundaryRecovery ||
1616
+ (useAdaptiveDensePlaneRouting &&
1617
+ ((params.planeReservationRetryCount ?? 0) === 0 ||
1618
+ Boolean(params.preferredBoundaryViaPoints)))
1610
1619
  const matchLengthsAfterPlanes =
1611
1620
  useConfiguredDensePlaneRouting &&
1612
1621
  params.lengthMatchingStage !== "before-planes"
@@ -1852,7 +1861,9 @@ export class FanoutSolver extends BaseSolver {
1852
1861
  process.env.FANOUT_DEBUG_BOUNDARY_ORDER?.split(",") ??
1853
1862
  (process.env.FANOUT_DEBUG_FIRST_BOUNDARY_BUS
1854
1863
  ? [process.env.FANOUT_DEBUG_FIRST_BOUNDARY_BUS]
1855
- : [])
1864
+ : params.boundaryRecovery
1865
+ ? [params.boundaryRecovery.busId]
1866
+ : [])
1856
1867
  const boundaryBuses =
1857
1868
  debugBoundaryOrder.length > 0
1858
1869
  ? initiallySortedBoundaryBuses.toSorted((first, second) => {
@@ -1914,6 +1925,7 @@ export class FanoutSolver extends BaseSolver {
1914
1925
  ]
1915
1926
  }
1916
1927
  const unroutablePlaneBusIds = new Set<string>()
1928
+ let failedWideBoundaryBus: PreparedBus | undefined
1917
1929
  debugDense(
1918
1930
  "start",
1919
1931
  boundaryBuses.map((bus) => `${bus.busId}:${bus.connections.length}`),
@@ -2015,6 +2027,16 @@ export class FanoutSolver extends BaseSolver {
2015
2027
  if (entersNeighboringSourceField)
2016
2028
  preferBoundaryOutwardByBusId.set(bus.busId, false)
2017
2029
  }
2030
+ if (params.boundaryRecovery) {
2031
+ preferredBoundaryPerpendicularSideByBusId.set(
2032
+ params.boundaryRecovery.busId,
2033
+ params.boundaryRecovery.perpendicularSide,
2034
+ )
2035
+ preferBoundaryOutwardByBusId.set(
2036
+ params.boundaryRecovery.busId,
2037
+ params.boundaryRecovery.preferOutward,
2038
+ )
2039
+ }
2018
2040
  const debugFlippedBoundaryBus = process.env.FANOUT_DEBUG_FLIP_BOUNDARY_BUS
2019
2041
  if (debugFlippedBoundaryBus) {
2020
2042
  preferredBoundaryPerpendicularSideByBusId.set(debugFlippedBoundaryBus, -1)
@@ -2653,6 +2675,7 @@ export class FanoutSolver extends BaseSolver {
2653
2675
  allowBoundarySideViaFallback: bus.connections.length === 1,
2654
2676
  preferCornerBoundaryVia: useConfiguredDensePlaneRouting,
2655
2677
  adaptiveWindingRouteOrder,
2678
+ allowFixedViaReservedExitFallback: useBoundaryRecovery,
2656
2679
  // Retain pad-aligned channels even when plane sites are reserved
2657
2680
  // adaptively; the boundary grid can fence off a turning wide bus.
2658
2681
  alignWindingGridToPads:
@@ -3035,6 +3058,7 @@ export class FanoutSolver extends BaseSolver {
3035
3058
  }
3036
3059
  }
3037
3060
  if (!busPlans) {
3061
+ if (bus.connections.length >= 8) failedWideBoundaryBus ??= bus
3038
3062
  debugDense("route:failed", bus.busId)
3039
3063
  return false
3040
3064
  }
@@ -3365,6 +3389,7 @@ export class FanoutSolver extends BaseSolver {
3365
3389
  promotedAlternatePlaneBusIds: ReadonlySet<string> = new Set(),
3366
3390
  ): Map<number, { x: number; y: number }> | null => {
3367
3391
  feasibleAlternatePlanePlans = []
3392
+ matchedPlaneBusesInRoutingOrder = null
3368
3393
  const fixedBoundaryViaPoints = new Map(
3369
3394
  candidatePlans.flatMap((plan) =>
3370
3395
  plan.via
@@ -3431,11 +3456,16 @@ export class FanoutSolver extends BaseSolver {
3431
3456
  if (retainedViaPoints)
3432
3457
  return new Map([...fixedBoundaryViaPoints, ...retainedViaPoints])
3433
3458
  if (
3434
- useConfiguredDensePlaneRouting ||
3459
+ useJointPlaneRepair ||
3435
3460
  process.env.FANOUT_DEBUG_INCREMENTAL_PLANE_MATCH === "1"
3436
3461
  ) {
3437
3462
  let incrementalViaPoints = new Map(fixedBoundaryViaPoints)
3438
- const matchedPlaneBuses = [...activeBoundaryReservationPlaneBuses]
3463
+ // Reservations guided the boundary search, but their copper is
3464
+ // still uncommitted. Recovery must choose local and longer plane
3465
+ // escapes together instead of locking every provisional site.
3466
+ const matchedPlaneBuses = useBoundaryRecovery
3467
+ ? []
3468
+ : [...activeBoundaryReservationPlaneBuses]
3439
3469
  for (const planeBus of matchedPlaneBuses) {
3440
3470
  for (const connection of planeBus.connections) {
3441
3471
  const reservedPoint = fixedViaPointsByConnectionIndex.get(
@@ -3581,7 +3611,7 @@ export class FanoutSolver extends BaseSolver {
3581
3611
  independentlyUnmatchablePlaneBuses.map((bus) => bus.busId),
3582
3612
  )
3583
3613
  if (
3584
- !useConfiguredDensePlaneRouting &&
3614
+ !useJointPlaneRepair &&
3585
3615
  process.env.FANOUT_DEBUG_ROUTE_UNMATCHED_PLANES !== "1"
3586
3616
  ) {
3587
3617
  return null
@@ -3596,7 +3626,9 @@ export class FanoutSolver extends BaseSolver {
3596
3626
  ? 10_000
3597
3627
  : useConfiguredDensePlaneRouting
3598
3628
  ? 3_000_000
3599
- : 1_000),
3629
+ : useBoundaryRecovery
3630
+ ? 10_000
3631
+ : 1_000),
3600
3632
  )
3601
3633
  const maximumAlternatePlaneRoutes = Number(
3602
3634
  process.env.FANOUT_DEBUG_ALTERNATE_ROUTE_COUNT ??
@@ -3774,7 +3806,7 @@ export class FanoutSolver extends BaseSolver {
3774
3806
  }
3775
3807
  let alternatePlanePlans: FanoutRoutePlan[] | null
3776
3808
  if (
3777
- useConfiguredDensePlaneRouting ||
3809
+ useJointPlaneRepair ||
3778
3810
  process.env.FANOUT_DEBUG_EXACT_COVER_ALTERNATES === "1"
3779
3811
  ) {
3780
3812
  type IndependentPlaneRouteCandidate = {
@@ -3795,7 +3827,7 @@ export class FanoutSolver extends BaseSolver {
3795
3827
  })),
3796
3828
  }),
3797
3829
  )
3798
- if (useAdaptiveJointPlaneSelection) {
3830
+ if (useJointPlaneSelection) {
3799
3831
  const acceptedPlans = [
3800
3832
  ...candidatePlans,
3801
3833
  ...feasibleAlternatePlanePlans,
@@ -4106,7 +4138,7 @@ export class FanoutSolver extends BaseSolver {
4106
4138
  )
4107
4139
  if (!alternatePlanePlans) return null
4108
4140
  feasibleAlternatePlanePlans = alternatePlanePlans
4109
- if (useAdaptiveJointPlaneSelection) {
4141
+ if (useJointPlaneSelection) {
4110
4142
  matchedPlaneBusesInRoutingOrder = []
4111
4143
  return new Map([
4112
4144
  ...fixedBoundaryViaPoints,
@@ -4620,6 +4652,33 @@ export class FanoutSolver extends BaseSolver {
4620
4652
  denseRoutingStrategy: "boundary-aligned",
4621
4653
  })
4622
4654
  if (boundaryAlignedState) return boundaryAlignedState
4655
+ // A later wide bus can be fenced by provisional through-vias even
4656
+ // though its requested exit order is routable. After both existing
4657
+ // grids fail, give that bus first choice of the field and retry the
4658
+ // four dogbone orientations. The marker bounds recursion and enables
4659
+ // joint selection of the remaining uncommitted plane escapes.
4660
+ if (!useBoundaryRecovery && failedWideBoundaryBus) {
4661
+ const busId = failedWideBoundaryBus.busId
4662
+ const outward = preferBoundaryOutwardByBusId.get(busId) ?? true
4663
+ const side = preferredBoundaryPerpendicularSideByBusId.get(busId) ?? 1
4664
+ for (const [preferOutward, perpendicularSide] of [
4665
+ [!outward, -side],
4666
+ [!outward, side],
4667
+ [outward, -side],
4668
+ [outward, side],
4669
+ ] as const) {
4670
+ const recoveredState =
4671
+ yield* this.routeDenseThroughAllMixedTerminationSteps({
4672
+ ...params,
4673
+ boundaryRecovery: {
4674
+ busId,
4675
+ preferOutward,
4676
+ perpendicularSide: perpendicularSide as -1 | 1,
4677
+ },
4678
+ })
4679
+ if (recoveredState) return recoveredState
4680
+ }
4681
+ }
4623
4682
  }
4624
4683
  if (
4625
4684
  !usePadAlignedDenseRouting ||
package/lib/geometry.ts CHANGED
@@ -190,6 +190,25 @@ export function segmentsAreClear(
190
190
  ): boolean {
191
191
  if (first.layer !== second.layer) return true
192
192
  const requiredDistance = (first.width + second.width) / 2 + clearance
193
+ // Axis separation is a lower bound on the distance between the segments.
194
+ // Leave a conservative tolerance band to the exact check below.
195
+ const broadPhaseDistance = requiredDistance + EPSILON
196
+ if (
197
+ Math.min(first.start.x, first.end.x) -
198
+ Math.max(second.start.x, second.end.x) >
199
+ broadPhaseDistance ||
200
+ Math.min(second.start.x, second.end.x) -
201
+ Math.max(first.start.x, first.end.x) >
202
+ broadPhaseDistance ||
203
+ Math.min(first.start.y, first.end.y) -
204
+ Math.max(second.start.y, second.end.y) >
205
+ broadPhaseDistance ||
206
+ Math.min(second.start.y, second.end.y) -
207
+ Math.max(first.start.y, first.end.y) >
208
+ broadPhaseDistance
209
+ ) {
210
+ return true
211
+ }
193
212
  return (
194
213
  distanceSegmentToSegment(
195
214
  first.start,
package/lib/route-bus.ts CHANGED
@@ -75,6 +75,8 @@ export interface RouteBusParams {
75
75
  alignWindingGridToPads?: boolean
76
76
  /** Bounds the final fixed-via winding fallback after ordered attempts. */
77
77
  fixedViaFallbackRouteOrderAttempts?: number
78
+ /** Retry caller-fixed sites while preserving future exit gaps during recovery. */
79
+ allowFixedViaReservedExitFallback?: boolean
78
80
  /** Skip this many otherwise-clear plane escapes when enumerating alternatives. */
79
81
  planeCandidateSkipCount?: number
80
82
  /** Dense corner-band phase that preserves existing lane centers when leading lanes are prepended. */
@@ -1951,6 +1953,8 @@ function planIsClearOfPlans(params: {
1951
1953
  clearance,
1952
1954
  blockingBusCounts,
1953
1955
  } = params
1956
+ const planSegments = getPlanSegments(plan)
1957
+ const planVias = getPlanVias(plan)
1954
1958
  for (const otherPlan of otherPlans) {
1955
1959
  if (
1956
1960
  allowSameNetMerges &&
@@ -1975,9 +1979,7 @@ function planIsClearOfPlans(params: {
1975
1979
  (blockingBusCounts.get(otherPlan.busId) ?? 0) + 1,
1976
1980
  )
1977
1981
  }
1978
- const planSegments = getPlanSegments(plan)
1979
1982
  const otherSegments = getPlanSegments(otherPlan)
1980
- const planVias = getPlanVias(plan)
1981
1983
  const otherVias = getPlanVias(otherPlan)
1982
1984
  for (const segment of planSegments) {
1983
1985
  for (const otherSegment of otherSegments) {
@@ -2606,6 +2608,7 @@ export function* routeBusAlternativesSteps(
2606
2608
  adaptiveWindingRouteOrder = false,
2607
2609
  alignWindingGridToPads = false,
2608
2610
  fixedViaFallbackRouteOrderAttempts = 24,
2611
+ allowFixedViaReservedExitFallback = false,
2609
2612
  cornerBandTargetTrackOffset,
2610
2613
  } = params
2611
2614
  if (!Number.isInteger(maxAlternatives) || maxAlternatives < 1) {
@@ -2994,6 +2997,29 @@ export function* routeBusAlternativesSteps(
2994
2997
  })),
2995
2998
  ).flat()
2996
2999
  : []),
3000
+ // A single-layer target permutation can also let an early lane
3001
+ // close a future terminal's exit gap. Keep the original attempts
3002
+ // first, then retry the same fixed sites with every exit reserved.
3003
+ ...(allowFixedViaReservedExitFallback &&
3004
+ fixedViaPointsByConnectionIndex &&
3005
+ !getCornerSide(bus) &&
3006
+ windingTargetOrderCount === 1 &&
3007
+ bus.connections.length > 2
3008
+ ? [true, false].map((preferTargetDirectedLaneBias) => ({
3009
+ label: `fixed-vias-reserved-exits-${preferTargetDirectedLaneBias}`,
3010
+ useViaInPad: false,
3011
+ getViaHandedness: () => 0 as const,
3012
+ getViaPoint: (connection: PreparedConnection) =>
3013
+ coordinatedViaPoints.get(connection.connectionIndex)!,
3014
+ maximumRouteOrderAttempts: Math.min(
3015
+ maximumThroughAllRouteOrderAttempts,
3016
+ fixedViaFallbackRouteOrderAttempts,
3017
+ ),
3018
+ windingOrderIndex: 0,
3019
+ preferTargetDirectedLaneBias,
3020
+ reserveTerminalExitPoints: true,
3021
+ }))
3022
+ : []),
2997
3023
  ]
2998
3024
  : []
2999
3025
  const planeTerminationsAlreadyOccupyTheFanout = acceptedPlans.some(
@@ -824,6 +824,13 @@ export function* routeViaMinimalWindingAlternativesSteps(
824
824
  const sharesNet = (first: string, second: string): boolean =>
825
825
  first === second ||
826
826
  (allowSameNetMerges && connectionsShareElectricalNet(srj, first, second))
827
+ const boundedBlockingSegments = blockingSegments.map((blocker) => ({
828
+ ...blocker,
829
+ minX: Math.min(blocker.segment.start.x, blocker.segment.end.x),
830
+ maxX: Math.max(blocker.segment.start.x, blocker.segment.end.x),
831
+ minY: Math.min(blocker.segment.start.y, blocker.segment.end.y),
832
+ maxY: Math.max(blocker.segment.start.y, blocker.segment.end.y),
833
+ }))
827
834
  const allBlockingVias = [...blockingVias, ...terminalVias]
828
835
  const maximumViaToTraceDistance = allBlockingVias.reduce(
829
836
  (maximum, { via }) =>
@@ -850,6 +857,10 @@ export function* routeViaMinimalWindingAlternativesSteps(
850
857
  }): boolean => {
851
858
  const { segment, terminal, acceptedAttemptSegments } = params
852
859
  const connectionName = terminal.connection.connection.name
860
+ const segmentMinX = Math.min(segment.start.x, segment.end.x)
861
+ const segmentMaxX = Math.max(segment.start.x, segment.end.x)
862
+ const segmentMinY = Math.min(segment.start.y, segment.end.y)
863
+ const segmentMaxY = Math.max(segment.start.y, segment.end.y)
853
864
  const requiredObstacleClearance = segment.width / 2 + clearance
854
865
  for (const obstacle of targetLayerObstacleIndex.querySegment(
855
866
  segment,
@@ -869,8 +880,19 @@ export function* routeViaMinimalWindingAlternativesSteps(
869
880
  return false
870
881
  }
871
882
  }
872
- for (const blocker of blockingSegments) {
883
+ for (const blocker of boundedBlockingSegments) {
873
884
  if (sharesNet(connectionName, blocker.connectionName)) continue
885
+ const margin = (segment.width + blocker.segment.width) / 2 + clearance
886
+ // Keep the full clearance margin in the broad phase; the exact check
887
+ // retains the existing tolerance for nearby copper.
888
+ if (
889
+ segmentMaxX + margin < blocker.minX ||
890
+ segmentMinX - margin > blocker.maxX ||
891
+ segmentMaxY + margin < blocker.minY ||
892
+ segmentMinY - margin > blocker.maxY
893
+ ) {
894
+ continue
895
+ }
874
896
  if (
875
897
  distanceSegmentToSegment(
876
898
  segment.start,
@@ -878,7 +900,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
878
900
  blocker.segment.start,
879
901
  blocker.segment.end,
880
902
  ) <
881
- (segment.width + blocker.segment.width) / 2 + clearance - EPSILON
903
+ margin - EPSILON
882
904
  ) {
883
905
  return false
884
906
  }
@@ -887,13 +909,13 @@ export function* routeViaMinimalWindingAlternativesSteps(
887
909
  if (sharesNet(connectionName, blocker.connectionName)) continue
888
910
  const margin = (segment.width + blocker.segment.width) / 2 + clearance
889
911
  if (
890
- Math.max(segment.start.x, segment.end.x) + margin <
912
+ segmentMaxX + margin <
891
913
  Math.min(blocker.segment.start.x, blocker.segment.end.x) ||
892
- Math.min(segment.start.x, segment.end.x) - margin >
914
+ segmentMinX - margin >
893
915
  Math.max(blocker.segment.start.x, blocker.segment.end.x) ||
894
- Math.max(segment.start.y, segment.end.y) + margin <
916
+ segmentMaxY + margin <
895
917
  Math.min(blocker.segment.start.y, blocker.segment.end.y) ||
896
- Math.min(segment.start.y, segment.end.y) - margin >
918
+ segmentMinY - margin >
897
919
  Math.max(blocker.segment.start.y, blocker.segment.end.y)
898
920
  )
899
921
  continue
@@ -917,10 +939,6 @@ export function* routeViaMinimalWindingAlternativesSteps(
917
939
  )
918
940
  return false
919
941
  }
920
- const segmentMinX = Math.min(segment.start.x, segment.end.x)
921
- const segmentMaxX = Math.max(segment.start.x, segment.end.x)
922
- const segmentMinY = Math.min(segment.start.y, segment.end.y)
923
- const segmentMaxY = Math.max(segment.start.y, segment.end.y)
924
942
  for (
925
943
  let viaIndex = getFirstViaAtOrAfterX(
926
944
  segmentMinX - maximumViaToTraceDistance,
@@ -1151,18 +1169,33 @@ export function* routeViaMinimalWindingAlternativesSteps(
1151
1169
  )
1152
1170
  const previous = new Int32Array(stateCount).fill(-1)
1153
1171
  const heap = new MinHeap()
1154
- const heuristic = (point: Point2D): number => {
1172
+ // A node is revisited with different incoming directions. Its distance
1173
+ // estimate and lane penalty stay fixed throughout this terminal search.
1174
+ const remainingDistances = new Float64Array(nodeCount)
1175
+ const lanePenalties = new Float64Array(nodeCount)
1176
+ const targetTrack = getPerpendicularAxis(
1177
+ terminal.exitPoint,
1178
+ boundaryDirection,
1179
+ )
1180
+ for (let nodeIndex = 0; nodeIndex < nodeCount; nodeIndex++) {
1181
+ const point = nodes[nodeIndex]!.point
1155
1182
  const deltaX = Math.abs(point.x - terminal.exitPoint.x)
1156
1183
  const deltaY = Math.abs(point.y - terminal.exitPoint.y)
1157
- return (
1184
+ remainingDistances[nodeIndex] =
1158
1185
  Math.max(deltaX, deltaY) + (Math.SQRT2 - 1) * Math.min(deltaX, deltaY)
1159
- )
1186
+ const nextTrack = getPerpendicularAxis(point, boundaryDirection)
1187
+ lanePenalties[nodeIndex] =
1188
+ laneBias === 0
1189
+ ? 0
1190
+ : laneBias > 0
1191
+ ? Math.max(0, targetTrack - nextTrack) * 0.2
1192
+ : Math.max(0, nextTrack - targetTrack) * 0.2
1160
1193
  }
1161
1194
  for (const start of starts) {
1162
1195
  const state = start.nodeIndex * 9 + 8
1163
1196
  if (start.length >= distances[state]!) continue
1164
1197
  distances[state] = start.length
1165
- const remaining = heuristic(nodes[start.nodeIndex]!.point)
1198
+ const remaining = remainingDistances[start.nodeIndex]!
1166
1199
  heap.push({
1167
1200
  node: start.nodeIndex,
1168
1201
  direction: 8,
@@ -1179,6 +1212,16 @@ export function* routeViaMinimalWindingAlternativesSteps(
1179
1212
  [0, -1],
1180
1213
  [1, -1],
1181
1214
  ] as const
1215
+ // Preserve the original ascending neighbor order, but do not reconsider
1216
+ // the five disallowed turns every time a directed state is expanded.
1217
+ const nextDirectionsByIncoming = Array.from({ length: 9 }, (_, incoming) =>
1218
+ directions.flatMap((_, directionIndex) => {
1219
+ const delta = Math.abs(incoming - directionIndex)
1220
+ return incoming === 8 || Math.min(delta, 8 - delta) <= 1
1221
+ ? [directionIndex]
1222
+ : []
1223
+ }),
1224
+ )
1182
1225
  const startsByNode = new Map<number, ConnectorCandidate[]>()
1183
1226
  for (const start of starts) {
1184
1227
  const values = startsByNode.get(start.nodeIndex) ?? []
@@ -1198,7 +1241,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
1198
1241
  const currentDistance = distances[state]!
1199
1242
  if (
1200
1243
  current.score >
1201
- currentDistance + heuristic(nodes[current.node]!.point) + EPSILON
1244
+ currentDistance + remainingDistances[current.node]! + EPSILON
1202
1245
  )
1203
1246
  continue
1204
1247
  expandedStateCount++
@@ -1257,17 +1300,9 @@ export function* routeViaMinimalWindingAlternativesSteps(
1257
1300
  }
1258
1301
  }
1259
1302
  const node = nodes[current.node]!
1260
- for (
1261
- let directionIndex = 0;
1262
- directionIndex < directions.length;
1263
- directionIndex++
1264
- ) {
1265
- if (current.direction !== 8) {
1266
- const rawDirectionDelta = Math.abs(current.direction - directionIndex)
1267
- if (Math.min(rawDirectionDelta, 8 - rawDirectionDelta) > 1) {
1268
- continue
1269
- }
1270
- }
1303
+ for (const directionIndex of nextDirectionsByIncoming[
1304
+ current.direction
1305
+ ]!) {
1271
1306
  const [deltaColumn, deltaRow] = directions[directionIndex]!
1272
1307
  const column = node.column + deltaColumn
1273
1308
  const row = node.row + deltaRow
@@ -1278,17 +1313,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
1278
1313
  const nextPoint = nodes[nextNode]!.point
1279
1314
  const addsTurn =
1280
1315
  current.direction !== 8 && current.direction !== directionIndex
1281
- const nextTrack = getPerpendicularAxis(nextPoint, boundaryDirection)
1282
- const targetTrack = getPerpendicularAxis(
1283
- terminal.exitPoint,
1284
- boundaryDirection,
1285
- )
1286
- const lanePenalty =
1287
- laneBias === 0
1288
- ? 0
1289
- : laneBias > 0
1290
- ? Math.max(0, targetTrack - nextTrack) * 0.2
1291
- : Math.max(0, nextTrack - targetTrack) * 0.2
1316
+ const lanePenalty = lanePenalties[nextNode]!
1292
1317
  const nextDistance =
1293
1318
  currentDistance +
1294
1319
  (deltaColumn !== 0 && deltaRow !== 0
@@ -1319,7 +1344,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
1319
1344
  if (edgeClearance[edgeIndex] === 2) continue
1320
1345
  distances[nextState] = nextDistance
1321
1346
  previous[nextState] = state
1322
- const remaining = heuristic(nextPoint)
1347
+ const remaining = remainingDistances[nextNode]!
1323
1348
  heap.push({
1324
1349
  node: nextNode,
1325
1350
  direction: directionIndex,
@@ -1488,6 +1513,14 @@ export function* routeViaMinimalWindingAlternativesSteps(
1488
1513
 
1489
1514
  const alternatives: FanoutRoutePlan[][] = []
1490
1515
  const seenAlternativeKeys = new Set<string>()
1516
+ // Different complete route orders can share the same unsuccessful prefix.
1517
+ // Static obstacles/vias, grid and search limits belong to this invocation;
1518
+ // the terminal, lane bias and already accepted copper identify the rest.
1519
+ // A reused failure still emits its normal completion progress below.
1520
+ const failedSearches = new Map<
1521
+ string,
1522
+ { expandedStateCount: number; searchBatch: number }
1523
+ >()
1491
1524
  let routeOrderAttemptCount = 0
1492
1525
  for (const routeOrder of adaptiveRouteOrders()) {
1493
1526
  for (const laneBias of laneBiases) {
@@ -1507,13 +1540,36 @@ export function* routeViaMinimalWindingAlternativesSteps(
1507
1540
  terminalIndex++
1508
1541
  ) {
1509
1542
  const terminal = routeOrder[terminalIndex]!
1543
+ const failedSearchKey = JSON.stringify([
1544
+ terminals.indexOf(terminal),
1545
+ laneBias,
1546
+ acceptedAttemptSegments.map(({ connectionName, segment }) => [
1547
+ connectionName,
1548
+ segment.start.x,
1549
+ segment.start.y,
1550
+ segment.end.x,
1551
+ segment.end.y,
1552
+ segment.width,
1553
+ segment.layer,
1554
+ ]),
1555
+ ])
1556
+ const cachedFailure = failedSearches.get(failedSearchKey)
1510
1557
  const connectionSteps = routeOneSteps({
1511
1558
  terminal,
1512
1559
  acceptedAttemptSegments,
1513
1560
  laneBias,
1514
1561
  })
1515
- let connectionResult = connectionSteps.next()
1516
- let searchBatch = 0
1562
+ let connectionResult: ReturnType<typeof connectionSteps.next> =
1563
+ cachedFailure === undefined
1564
+ ? connectionSteps.next()
1565
+ : {
1566
+ done: true,
1567
+ value: {
1568
+ points: null,
1569
+ expandedStateCount: cachedFailure.expandedStateCount,
1570
+ },
1571
+ }
1572
+ let searchBatch = cachedFailure?.searchBatch ?? 0
1517
1573
  let expandedStateCount = 0
1518
1574
  while (!connectionResult.done) {
1519
1575
  expandedStateCount = connectionResult.value.expandedStateCount
@@ -1560,6 +1616,10 @@ export function* routeViaMinimalWindingAlternativesSteps(
1560
1616
  : {}),
1561
1617
  }
1562
1618
  if (!points) {
1619
+ failedSearches.set(failedSearchKey, {
1620
+ expandedStateCount: finalExpandedStateCount,
1621
+ searchBatch,
1622
+ })
1563
1623
  if (
1564
1624
  adaptiveRouteOrder &&
1565
1625
  maximumRouteOrderAttempts !== undefined &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.62",
3
+ "version": "0.0.64",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",