@tscircuit/fanout-solver 0.0.58 → 0.0.60

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
@@ -330,6 +330,11 @@ the solver commit, dataset revision, configuration, solve totals, every sample's
330
330
  status and timing, and partial routing/validation counts. Reports are saved after
331
331
  every completed sample, including the total selected count to identify incomplete runs.
332
332
  Timed-out workers do not retain their in-flight routing counts.
333
+ Every solved case also writes `benchmark-results/<sample-id>.svg`. These SVGs
334
+ are committed so route changes can be reviewed in Git. A run replaces the selected
335
+ cases' snapshots and removes their stale SVGs if they no longer solve; filtered
336
+ runs preserve unselected snapshots. JSON reports and captured inputs remain
337
+ ignored. CI includes the SVGs in its benchmark artifacts.
333
338
  Compare reports with the same budgets to track progress. Solved means validated
334
339
  AM62L fanout, not RAM fanout or downstream inter-chip routing. Partial, error,
335
340
  and timeout rows are benchmark results (exit 0); invalid CLI arguments or report
@@ -5,7 +5,11 @@ import { refineAdaptivePlaneReservationCore } from "./refine-adaptive-plane-rese
5
5
  import { shouldUseAdaptiveDensePlaneRouting } from "./should-use-adaptive-dense-plane-routing"
6
6
  import { type GraphicsObject, mergeGraphics } from "graphics-debug"
7
7
  import { addViaLayerMetadataToSrj } from "./add-via-layer-metadata"
8
- import { getCornerBandSide, getExitEdgeForDirection } from "./boundary-exit"
8
+ import {
9
+ getCornerBandSide,
10
+ getDirectionForExitEdge,
11
+ getExitEdgeForDirection,
12
+ } from "./boundary-exit"
9
13
  import { buildOutputSimpleRouteJson } from "./build-output"
10
14
  import {
11
15
  type CompleteOriginalEndpointsResult,
@@ -1397,9 +1401,14 @@ export class FanoutSolver extends BaseSolver {
1397
1401
  denseRoutingStrategy?: "pad-aligned" | "boundary-aligned"
1398
1402
  lengthMatchingStage?: "before-planes" | "after-planes"
1399
1403
  promotedPlaneReservationBusIds?: readonly string[]
1404
+ preferredBoundaryViaPoints?: ReadonlyMap<number, { x: number; y: number }>
1400
1405
  planeReservationRetryCount?: number
1401
1406
  }): Generator<FanoutWorkYield, MixedTerminationState | null, unknown> {
1402
1407
  if (this.config.allowBlindAndBuriedVias) return null
1408
+ // An outside-package singleton escape can depend on the completed signal
1409
+ // field. Keep those sites as preferences when plane reservations change;
1410
+ // actual conflicts may still move.
1411
+ let boundaryViaPointsForRetry = params.preferredBoundaryViaPoints
1403
1412
  const usePadAlignedDenseRouting =
1404
1413
  params.denseRoutingStrategy !== "boundary-aligned"
1405
1414
  const debugDense = (...values: unknown[]) => {
@@ -1444,9 +1453,12 @@ export class FanoutSolver extends BaseSolver {
1444
1453
  )
1445
1454
  const useConfiguredDensePlaneRouting =
1446
1455
  configuredDensePlaneRouting || useAdaptiveDensePlaneRouting
1456
+ // A completed boundary assignment remains worth repairing jointly after
1457
+ // plane reservations change; a greedy refill can discard that assignment.
1447
1458
  const useAdaptiveJointPlaneSelection =
1448
1459
  useAdaptiveDensePlaneRouting &&
1449
- (params.planeReservationRetryCount ?? 0) === 0
1460
+ ((params.planeReservationRetryCount ?? 0) === 0 ||
1461
+ Boolean(params.preferredBoundaryViaPoints))
1450
1462
  const matchLengthsAfterPlanes =
1451
1463
  useConfiguredDensePlaneRouting &&
1452
1464
  params.lengthMatchingStage !== "before-planes"
@@ -1871,19 +1883,45 @@ export class FanoutSolver extends BaseSolver {
1871
1883
  )
1872
1884
  })
1873
1885
  : []
1886
+ // A corner singleton on the outward source row must leave before its
1887
+ // surrounding wide bus closes the shared target-layer corridor.
1874
1888
  const throughAllLeadingSingletonBuses = hasThreeWideBoundaryBuses
1875
1889
  ? singletonBoundaryBuses.filter((singletonBus) => {
1876
1890
  const containingWideBus = getContainingWideSourceField(singletonBus)
1877
1891
  const singletonTargetLayer =
1878
1892
  params.busLayerAssignments[singletonBus.busId]
1893
+ if (!containingWideBus || !singletonTargetLayer) return false
1879
1894
  const containingWideLayers =
1880
- containingWideBus?.routableEscapeLayers ??
1881
- containingWideBus?.allowedLayers ??
1895
+ containingWideBus.routableEscapeLayers ??
1896
+ containingWideBus.allowedLayers ??
1882
1897
  []
1898
+ const sourceAxis =
1899
+ singletonBus.exitEdge === "left" ||
1900
+ singletonBus.exitEdge === "right"
1901
+ ? "x"
1902
+ : "y"
1903
+ const wideCoordinates = containingWideBus.connections.map(
1904
+ (connection) => connection.sourcePoint[sourceAxis],
1905
+ )
1906
+ const outwardSourceCoordinate =
1907
+ singletonBus.exitEdge === "left" ||
1908
+ singletonBus.exitEdge === "bottom"
1909
+ ? Math.min(...wideCoordinates)
1910
+ : Math.max(...wideCoordinates)
1911
+ const isOnOutwardSourceEdge = singletonBus.connections.every(
1912
+ (connection) =>
1913
+ Math.abs(
1914
+ connection.sourcePoint[sourceAxis] - outwardSourceCoordinate,
1915
+ ) < 1e-9,
1916
+ )
1883
1917
  return Boolean(
1884
- containingWideBus &&
1885
- singletonTargetLayer &&
1886
- !containingWideLayers.includes(singletonTargetLayer),
1918
+ !containingWideLayers.includes(singletonTargetLayer) ||
1919
+ (useAdaptiveDensePlaneRouting &&
1920
+ isOnOutwardSourceEdge &&
1921
+ getCornerBandSide(
1922
+ singletonBus.exitEdge,
1923
+ singletonBus.preferredExit,
1924
+ )),
1887
1925
  )
1888
1926
  })
1889
1927
  : []
@@ -1948,14 +1986,35 @@ export class FanoutSolver extends BaseSolver {
1948
1986
  : []),
1949
1987
  ...singletonDeferralCandidates.filter((bus) => {
1950
1988
  const containingBus = getContainingWideSourceField(bus)
1951
- const sharesContainingBusLayer =
1989
+ const sourcePoint = bus.connections[0]!.sourcePoint
1990
+ const componentCenter = {
1991
+ x: (bus.componentBounds.minX + bus.componentBounds.maxX) / 2,
1992
+ y: (bus.componentBounds.minY + bus.componentBounds.maxY) / 2,
1993
+ }
1994
+ const boundaryDirection = bus.exitEdge
1995
+ ? getDirectionForExitEdge(bus.exitEdge)
1996
+ : bus.direction
1997
+ const inwardProjection =
1998
+ boundaryDirection === "right"
1999
+ ? componentCenter.x - sourcePoint.x
2000
+ : boundaryDirection === "left"
2001
+ ? sourcePoint.x - componentCenter.x
2002
+ : boundaryDirection === "up"
2003
+ ? componentCenter.y - sourcePoint.y
2004
+ : sourcePoint.y - componentCenter.y
2005
+ // Crossing the component can consume an embedded singleton's source
2006
+ // dogbone even when the target layers differ. Keep outward boundary
2007
+ // escapes provisional unless they share the wide bus's target layer.
2008
+ const reserveEmbeddedSourceEscape =
1952
2009
  usePadAlignedDenseRouting &&
1953
2010
  !useConfiguredDensePlaneRouting &&
1954
2011
  containingBus &&
1955
- params.busLayerAssignments[containingBus.busId] ===
1956
- params.busLayerAssignments[bus.busId]
2012
+ (params.busLayerAssignments[containingBus.busId] ===
2013
+ params.busLayerAssignments[bus.busId] ||
2014
+ inwardProjection > 1e-9)
1957
2015
  return (
1958
- !leadingWideSingletonBuses.includes(bus) && !sharesContainingBusLayer
2016
+ !leadingWideSingletonBuses.includes(bus) &&
2017
+ !reserveEmbeddedSourceEscape
1959
2018
  )
1960
2019
  }),
1961
2020
  ...(hasThreeWideBoundaryBuses
@@ -1994,6 +2053,8 @@ export class FanoutSolver extends BaseSolver {
1994
2053
  traceWidth: this.config.traceWidth,
1995
2054
  clearance: this.config.clearance,
1996
2055
  maximumSearchStates: 100_000,
2056
+ preferredViaPointsByConnectionIndex:
2057
+ params.preferredBoundaryViaPoints,
1997
2058
  preferredBoundaryPerpendicularSideByBusId,
1998
2059
  preferBoundaryOutwardByBusId,
1999
2060
  additionalObstacles: denseAdditionalObstacles,
@@ -2012,6 +2073,8 @@ export class FanoutSolver extends BaseSolver {
2012
2073
  traceWidth: this.config.traceWidth,
2013
2074
  clearance: this.config.clearance,
2014
2075
  maximumSearchStates: 20_000,
2076
+ preferredViaPointsByConnectionIndex:
2077
+ params.preferredBoundaryViaPoints,
2015
2078
  preferredBoundaryPerpendicularSideByBusId,
2016
2079
  preferBoundaryOutwardByBusId,
2017
2080
  additionalObstacles: denseAdditionalObstacles,
@@ -2070,6 +2133,8 @@ export class FanoutSolver extends BaseSolver {
2070
2133
  traceWidth: this.config.traceWidth,
2071
2134
  clearance: this.config.clearance,
2072
2135
  maximumSearchStates: 100_000,
2136
+ preferredViaPointsByConnectionIndex:
2137
+ params.preferredBoundaryViaPoints,
2073
2138
  preferredBoundaryPerpendicularSideByBusId,
2074
2139
  preferBoundaryOutwardByBusId,
2075
2140
  fixedViaPointsByConnectionIndex: seedViaPoints,
@@ -2124,6 +2189,117 @@ export class FanoutSolver extends BaseSolver {
2124
2189
  bus,
2125
2190
  ]),
2126
2191
  ]
2192
+ const areAdjacentInvertedNarrowBuses = (
2193
+ first: PreparedBus,
2194
+ second: PreparedBus,
2195
+ ): boolean => {
2196
+ if (
2197
+ !usePadAlignedDenseRouting ||
2198
+ useConfiguredDensePlaneRouting ||
2199
+ first.componentId !== second.componentId ||
2200
+ first.exitEdge !== second.exitEdge ||
2201
+ first.direction !== second.direction ||
2202
+ getCornerBandSide(first.exitEdge, first.preferredExit) !==
2203
+ getCornerBandSide(second.exitEdge, second.preferredExit) ||
2204
+ params.busLayerAssignments[first.busId] !==
2205
+ params.busLayerAssignments[second.busId] ||
2206
+ getContainingWideSourceField(first) !==
2207
+ getContainingWideSourceField(second) ||
2208
+ !getContainingWideSourceField(first)
2209
+ )
2210
+ return false
2211
+ const firstCenter = {
2212
+ x:
2213
+ first.connections.reduce(
2214
+ (sum, connection) => sum + connection.sourcePoint.x,
2215
+ 0,
2216
+ ) / first.connections.length,
2217
+ y:
2218
+ first.connections.reduce(
2219
+ (sum, connection) => sum + connection.sourcePoint.y,
2220
+ 0,
2221
+ ) / first.connections.length,
2222
+ }
2223
+ const secondCenter = {
2224
+ x:
2225
+ second.connections.reduce(
2226
+ (sum, connection) => sum + connection.sourcePoint.x,
2227
+ 0,
2228
+ ) / second.connections.length,
2229
+ y:
2230
+ second.connections.reduce(
2231
+ (sum, connection) => sum + connection.sourcePoint.y,
2232
+ 0,
2233
+ ) / second.connections.length,
2234
+ }
2235
+ if (
2236
+ Math.hypot(
2237
+ firstCenter.x - secondCenter.x,
2238
+ firstCenter.y - secondCenter.y,
2239
+ ) >
2240
+ 1.5 * Math.min(first.pitchX, first.pitchY)
2241
+ )
2242
+ return false
2243
+ const axis =
2244
+ first.exitEdge === "left" || first.exitEdge === "right" ? "y" : "x"
2245
+ const targetTrack = (bus: PreparedBus) =>
2246
+ bus.connections.reduce(
2247
+ (sum, connection) =>
2248
+ sum +
2249
+ (connection.exitTargetPoint ?? connection.targetPoint)[axis],
2250
+ 0,
2251
+ ) / bus.connections.length
2252
+ return (
2253
+ (firstCenter[axis] - secondCenter[axis]) *
2254
+ (targetTrack(first) - targetTrack(second)) <
2255
+ -1e-9
2256
+ )
2257
+ }
2258
+ // Preserve a centered pair's channel before its leading singleton. A
2259
+ // turning pair instead leaves its adjacent singleton room to escape
2260
+ // before searching for an outside-package via.
2261
+ const centeredPairPromotionGroups = new Set<string>()
2262
+ for (const singleton of multiLayerLeadingSingletonBuses) {
2263
+ if (getCornerBandSide(singleton.exitEdge, singleton.preferredExit))
2264
+ continue
2265
+ for (const pair of boundaryBuses) {
2266
+ if (
2267
+ pair.connections.length !== 2 ||
2268
+ !areAdjacentInvertedNarrowBuses(pair, singleton)
2269
+ )
2270
+ continue
2271
+ const pairIndex = denseBoundaryBusesInRoutingOrder.indexOf(pair)
2272
+ const singletonIndex =
2273
+ denseBoundaryBusesInRoutingOrder.indexOf(singleton)
2274
+ if (pairIndex > singletonIndex) {
2275
+ denseBoundaryBusesInRoutingOrder.splice(pairIndex, 1)
2276
+ denseBoundaryBusesInRoutingOrder.splice(singletonIndex, 0, pair)
2277
+ centeredPairPromotionGroups.add(
2278
+ `${pair.componentId}:${pair.exitEdge}`,
2279
+ )
2280
+ }
2281
+ }
2282
+ }
2283
+ for (const pair of boundaryBuses) {
2284
+ if (
2285
+ !centeredPairPromotionGroups.has(
2286
+ `${pair.componentId}:${pair.exitEdge}`,
2287
+ ) ||
2288
+ pair.connections.length !== 2 ||
2289
+ !getCornerBandSide(pair.exitEdge, pair.preferredExit)
2290
+ )
2291
+ continue
2292
+ for (const singleton of singletonBoundaryBuses) {
2293
+ if (!areAdjacentInvertedNarrowBuses(singleton, pair)) continue
2294
+ const singletonIndex =
2295
+ denseBoundaryBusesInRoutingOrder.indexOf(singleton)
2296
+ const pairIndex = denseBoundaryBusesInRoutingOrder.indexOf(pair)
2297
+ if (singletonIndex > pairIndex) {
2298
+ denseBoundaryBusesInRoutingOrder.splice(singletonIndex, 1)
2299
+ denseBoundaryBusesInRoutingOrder.splice(pairIndex, 0, singleton)
2300
+ }
2301
+ }
2302
+ }
2127
2303
  let fixedViaPointsByConnectionIndex: ReadonlyMap<
2128
2304
  number,
2129
2305
  { x: number; y: number }
@@ -2221,8 +2397,10 @@ export class FanoutSolver extends BaseSolver {
2221
2397
  allowBoundarySideViaFallback: bus.connections.length === 1,
2222
2398
  preferCornerBoundaryVia: useConfiguredDensePlaneRouting,
2223
2399
  adaptiveWindingRouteOrder,
2400
+ // Retain pad-aligned channels even when plane sites are reserved
2401
+ // adaptively; the boundary grid can fence off a turning wide bus.
2224
2402
  alignWindingGridToPads:
2225
- usePadAlignedDenseRouting && !useConfiguredDensePlaneRouting,
2403
+ usePadAlignedDenseRouting && !configuredDensePlaneRouting,
2226
2404
  fixedViaFallbackRouteOrderAttempts: adaptiveWindingRouteOrder
2227
2405
  ? 60
2228
2406
  : useConfiguredDensePlaneRouting
@@ -2269,6 +2447,7 @@ export class FanoutSolver extends BaseSolver {
2269
2447
  useConfiguredDensePlaneRouting &&
2270
2448
  singleLayerBus !== bus &&
2271
2449
  !embeddedNarrowBusAlreadyRouted
2450
+ let usedSoftPlaneRepair = false
2272
2451
  let busPlans = (yield* routeAlternatives(
2273
2452
  preferSingleLayerWinding
2274
2453
  ? { ...routeParams, bus: singleLayerBus }
@@ -2375,6 +2554,86 @@ export class FanoutSolver extends BaseSolver {
2375
2554
  }
2376
2555
  }
2377
2556
  }
2557
+ // Once wide-bus copper constrains the field, retry a blocked wide bus
2558
+ // with provisional plane sites as search costs. Future boundary sites
2559
+ // remain fixed, and a complete joint rematch must validate the repair.
2560
+ if (
2561
+ !busPlans &&
2562
+ bus.connections.length >= 8 &&
2563
+ boundaryBuses.some(
2564
+ (candidate) =>
2565
+ candidate.connections.length >= 8 &&
2566
+ matchedPlans.some((plan) => plan.busId === candidate.busId),
2567
+ )
2568
+ ) {
2569
+ const committedNames = new Set(
2570
+ matchedPlans.map((plan) => plan.connectionName),
2571
+ )
2572
+ const futurePlaneNames = new Set(
2573
+ this.preparedBuses
2574
+ .filter((candidate) => candidate.termination.type === "plane")
2575
+ .flatMap((candidate) =>
2576
+ candidate.connections.map(
2577
+ (connection) => connection.connection.name,
2578
+ ),
2579
+ )
2580
+ .filter((name) => !committedNames.has(name)),
2581
+ )
2582
+ const freePlans = (yield* routeAlternatives(
2583
+ {
2584
+ ...routeParams,
2585
+ fixedViaPointsByConnectionIndex: undefined,
2586
+ reservedVias: routeParams.reservedVias.filter(
2587
+ (reserved) => !futurePlaneNames.has(reserved.connectionName),
2588
+ ),
2589
+ softReservedVias: routeParams.reservedVias.filter((reserved) =>
2590
+ futurePlaneNames.has(reserved.connectionName),
2591
+ ),
2592
+ },
2593
+ 1,
2594
+ ))[0]
2595
+ if (freePlans) {
2596
+ const allPlans = [...matchedPlans, ...freePlans]
2597
+ const rematchedPoints = matchComponentDogboneViaSites(
2598
+ this.preparedBuses,
2599
+ {
2600
+ viaDiameter: this.config.viaDiameter,
2601
+ viaHoleDiameter: this.config.viaHoleDiameter,
2602
+ traceWidth: this.config.traceWidth,
2603
+ clearance: this.config.clearance,
2604
+ maximumSearchStates: 3_000_000,
2605
+ preferredBoundaryPerpendicularSideByBusId,
2606
+ preferBoundaryOutwardByBusId,
2607
+ fixedViaPointsByConnectionIndex: new Map(
2608
+ allPlans
2609
+ .filter((plan) => plan.via)
2610
+ .map((plan) => [plan.connectionIndex, plan.via!.center]),
2611
+ ),
2612
+ preferredViaPointsByConnectionIndex:
2613
+ fixedViaPointsByConnectionIndex,
2614
+ blockingSegments: allPlans.flatMap((plan) =>
2615
+ plan.segments.map((segment) => ({
2616
+ connectionIndex: plan.connectionIndex,
2617
+ segment,
2618
+ })),
2619
+ ),
2620
+ additionalObstacles: this.routingSrj.obstacles,
2621
+ preferPlaneCheckerboardSites: useConfiguredDensePlaneRouting,
2622
+ canShareCopper,
2623
+ },
2624
+ )
2625
+ debugDense(
2626
+ "free-sites:rematched",
2627
+ bus.busId,
2628
+ rematchedPoints?.size ?? "failed",
2629
+ )
2630
+ if (rematchedPoints) {
2631
+ busPlans = freePlans
2632
+ fixedViaPointsByConnectionIndex = rematchedPoints
2633
+ usedSoftPlaneRepair = true
2634
+ }
2635
+ }
2636
+ }
2378
2637
  if (busPlans && bus.maxLengthSkew !== undefined) {
2379
2638
  const lengths = busPlans.map((plan) => plan.length)
2380
2639
  const rawSkew = Math.max(...lengths) - Math.min(...lengths)
@@ -2388,7 +2647,13 @@ export class FanoutSolver extends BaseSolver {
2388
2647
  // Only pay for additional A* variants when the first topology is so
2389
2648
  // skewed that compact meanders are unlikely to absorb the deficit.
2390
2649
  // This keeps already-near-matched buses on the single-attempt path.
2391
- if (needsRouteDiversity && !matchLengthsAfterPlanes) {
2650
+ // Keep the jointly rematched repair: routeParams still carries the
2651
+ // earlier provisional sites and cannot safely replace its geometry.
2652
+ if (
2653
+ needsRouteDiversity &&
2654
+ !matchLengthsAfterPlanes &&
2655
+ !usedSoftPlaneRepair
2656
+ ) {
2392
2657
  busPlans = (yield* routeAlternatives(routeParams, 3)).toSorted(
2393
2658
  (first, second) => {
2394
2659
  const firstLengths = first.map((plan) => plan.length)
@@ -2407,6 +2672,31 @@ export class FanoutSolver extends BaseSolver {
2407
2672
  return false
2408
2673
  }
2409
2674
  matchedPlans.push(...busPlans)
2675
+ if (
2676
+ matchedPlans.length ===
2677
+ boundaryBuses.reduce(
2678
+ (sum, candidate) => sum + candidate.connections.length,
2679
+ 0,
2680
+ ) &&
2681
+ matchedPlans.some((plan) => {
2682
+ const bus = boundaryBuses.find(
2683
+ (candidate) => candidate.busId === plan.busId,
2684
+ )
2685
+ if (bus?.connections.length !== 1 || !plan.via) return false
2686
+ const { center } = plan.via
2687
+ return (
2688
+ center.x < bus.componentBounds.minX ||
2689
+ center.x > bus.componentBounds.maxX ||
2690
+ center.y < bus.componentBounds.minY ||
2691
+ center.y > bus.componentBounds.maxY
2692
+ )
2693
+ })
2694
+ )
2695
+ boundaryViaPointsForRetry = new Map(
2696
+ matchedPlans
2697
+ .filter((plan) => plan.via)
2698
+ .map((plan) => [plan.connectionIndex, plan.via!.center]),
2699
+ )
2410
2700
  debugDense("route:complete", bus.busId, busPlans.length)
2411
2701
  return true
2412
2702
  }.bind(this)
@@ -2932,7 +3222,14 @@ export class FanoutSolver extends BaseSolver {
2932
3222
  let alternatePlaneSearchStates = 0
2933
3223
  const maximumAlternatePlaneSearchStates = Number(
2934
3224
  process.env.FANOUT_DEBUG_ALTERNATE_SEARCH_STATES ??
2935
- (useConfiguredDensePlaneRouting ? 3_000_000 : 1_000),
3225
+ // Keep the initial search short before reserving blocked
3226
+ // plane sites. Later joint repairs retain the full budget.
3227
+ (useAdaptiveDensePlaneRouting &&
3228
+ (params.planeReservationRetryCount ?? 0) === 0
3229
+ ? 10_000
3230
+ : useConfiguredDensePlaneRouting
3231
+ ? 3_000_000
3232
+ : 1_000),
2936
3233
  )
2937
3234
  const maximumAlternatePlaneRoutes = Number(
2938
3235
  process.env.FANOUT_DEBUG_ALTERNATE_ROUTE_COUNT ??
@@ -3838,6 +4135,7 @@ export class FanoutSolver extends BaseSolver {
3838
4135
  allowSameNetMerges: this.config.allowSameNetMerges,
3839
4136
  allowMatchingInsideDenseBounds: true,
3840
4137
  allowPairLaneSpreading: true,
4138
+ allowUnconstrainedLaneRerouting: true,
3841
4139
  }
3842
4140
  let matchedLengthResult = matchBusPlanLengths(lengthMatchingParams)
3843
4141
  const shortenedBusIds = new Set<string>()
@@ -3927,6 +4225,7 @@ export class FanoutSolver extends BaseSolver {
3927
4225
  debugDense("promote-reservations", refinedPromotions)
3928
4226
  return yield* this.routeDenseThroughAllMixedTerminationSteps({
3929
4227
  ...params,
4228
+ preferredBoundaryViaPoints: boundaryViaPointsForRetry,
3930
4229
  promotedPlaneReservationBusIds: [
3931
4230
  ...(params.promotedPlaneReservationBusIds ?? []),
3932
4231
  ...refinedPromotions,
@@ -8,7 +8,9 @@ import {
8
8
  distanceSegmentToSegment,
9
9
  segmentsAreClear,
10
10
  } from "./geometry"
11
- import { fanoutPlansAreClear } from "./route-bus"
11
+ import { getCopperLayerNames } from "./layer-names"
12
+ import { fanoutPlansAreClear, fanoutPlansAreMutuallyClear } from "./route-bus"
13
+ import { routeViaMinimalWinding } from "./route-via-minimal-winding"
12
14
  import type {
13
15
  Bounds,
14
16
  FanoutRoutePlan,
@@ -647,6 +649,8 @@ export function matchBusPlanLengths(params: {
647
649
  allowMatchingInsideDenseBounds?: boolean
648
650
  /** Allow a differential pair's longer lane to move aside before tuning its mate. */
649
651
  allowPairLaneSpreading?: boolean
652
+ /** Allow one unconstrained boundary lane to move around a tuning meander. */
653
+ allowUnconstrainedLaneRerouting?: boolean
650
654
  /**
651
655
  * Rejects a geometrically clear candidate when it would make a caller-owned
652
656
  * downstream assignment (such as pending plane dogbones) infeasible.
@@ -866,6 +870,124 @@ export function matchBusPlanLengths(params: {
866
870
  if (++attempts >= 4) break
867
871
  }
868
872
  }
873
+ if (!acceptedPlans && params.allowUnconstrainedLaneRerouting) {
874
+ // A neighboring singleton may occupy the only tuning window. Keep its
875
+ // source dogbone, via and boundary endpoint fixed, and reroute only its
876
+ // target-layer copper around a complete meander. Constrained buses and
877
+ // plane routes are never displaced by this bounded repair.
878
+ let rerouteAttempts = 0
879
+ candidateSearch: for (const targetAddedLength of targetAddedLengths) {
880
+ const candidates = createTunedPlanCandidates({
881
+ plan: shortest,
882
+ bus,
883
+ targetAddedLength,
884
+ clearance,
885
+ sharedBoundary: bus.sharedBoundary,
886
+ allowInsideDenseBounds: allowMatchingInsideDenseBounds,
887
+ })
888
+ for (const candidate of candidates) {
889
+ if (
890
+ !fanoutPlansAreClear({
891
+ plans: [candidate],
892
+ srj: inputSrj,
893
+ sharedBoundary,
894
+ clearance,
895
+ allowBlindAndBuriedVias,
896
+ allowSameNetMerges,
897
+ })
898
+ )
899
+ continue
900
+ const blockers = matchedPlans.filter(
901
+ (plan) =>
902
+ plan !== shortest &&
903
+ !fanoutPlansAreMutuallyClear({
904
+ plans: [candidate, plan],
905
+ srj: inputSrj,
906
+ clearance,
907
+ allowSameNetMerges,
908
+ }),
909
+ )
910
+ if (blockers.length !== 1) continue
911
+ const blocker = blockers[0]!
912
+ const blockerBus = preparedBuses.find(
913
+ (prepared) => prepared.busId === blocker.busId,
914
+ )
915
+ if (
916
+ !blockerBus ||
917
+ blockerBus.termination.type !== "boundary" ||
918
+ blockerBus.maxLengthSkew !== undefined ||
919
+ blockerBus.connections.length !== 1 ||
920
+ !blocker.via ||
921
+ blocker.additionalVias?.length ||
922
+ blocker.planeEndpointVia ||
923
+ blocker.segments.filter(
924
+ (segment) => segment.layer === blocker.sourceLayer,
925
+ ).length !== 1
926
+ )
927
+ continue
928
+ const nextPlans = matchedPlans.map((plan) =>
929
+ plan === shortest ? candidate : plan,
930
+ )
931
+ for (const alignGridToPads of [true, false]) {
932
+ if (rerouteAttempts++ >= 4) break candidateSearch
933
+ const rerouted = routeViaMinimalWinding({
934
+ srj: inputSrj,
935
+ bus: blockerBus,
936
+ terminals: [
937
+ {
938
+ connection: blockerBus.connections[0]!,
939
+ viaPoint: blocker.via.center,
940
+ exitPoint: blocker.exitPoint,
941
+ },
942
+ ],
943
+ targetLayer: blocker.targetLayer,
944
+ acceptedPlans: nextPlans.filter((plan) => plan !== blocker),
945
+ layerNames: getCopperLayerNames(inputSrj.layerCount),
946
+ traceWidth: blocker.segments[0]!.width,
947
+ viaDiameter: blocker.via.diameter,
948
+ viaHoleDiameter: blocker.via.holeDiameter,
949
+ clearance,
950
+ allowBlindAndBuriedVias,
951
+ allowSameNetMerges,
952
+ gridStepDivisor: 2,
953
+ alignGridToPads,
954
+ maximumRouteOrderAttempts: 1,
955
+ preferTargetDirectedLaneBias: true,
956
+ })?.[0]
957
+ if (!rerouted) continue
958
+ // Rebuild from the original plan to preserve its route identity,
959
+ // endpoint metadata and physical via span exactly.
960
+ const repaired = createPlanWithSegments(blocker, [
961
+ blocker.segments[0]!,
962
+ ...rerouted.segments.slice(1),
963
+ ])
964
+ if (!repaired) continue
965
+ const repairedPlans = nextPlans.map((plan) =>
966
+ plan === blocker ? repaired : plan,
967
+ )
968
+ if (
969
+ getBusSkew(
970
+ repairedPlans.filter((plan) => plan.busId === bus.busId),
971
+ ) >
972
+ maxLengthSkew + EPSILON ||
973
+ !fanoutPlansAreClear({
974
+ plans: repairedPlans,
975
+ srj: inputSrj,
976
+ sharedBoundary,
977
+ clearance,
978
+ allowBlindAndBuriedVias,
979
+ allowSameNetMerges,
980
+ }) ||
981
+ (candidatePlansAreFeasible &&
982
+ !candidatePlansAreFeasible(repairedPlans))
983
+ )
984
+ continue
985
+ acceptedPlans = repairedPlans
986
+ break candidateSearch
987
+ }
988
+ }
989
+ }
990
+ }
869
991
  if (!acceptedPlans) return { plans: null, failedBus: bus }
870
992
  matchedPlans = acceptedPlans
871
993
  }
package/lib/route-bus.ts CHANGED
@@ -62,6 +62,8 @@ export interface RouteBusParams {
62
62
  stopAfterFirstRejectedViaMinimalCandidate?: boolean
63
63
  fixedViaPointsByConnectionIndex?: ReadonlyMap<number, Point2D>
64
64
  reservedVias?: readonly ViaMinimalWindingReservedVia[]
65
+ /** Provisional site preferences; successful callers must rematch future vias. */
66
+ softReservedVias?: readonly ViaMinimalWindingReservedVia[]
65
67
  viaMinimalOnly?: boolean
66
68
  /** Permit a singleton or pair to move provisional vias near the boundary. */
67
69
  allowBoundarySideViaFallback?: boolean
@@ -311,10 +313,89 @@ function getWindingCrossoverLayer(params: {
311
313
  )
312
314
  }
313
315
 
316
+ function getDistributedBoundaryTargetTracks(params: {
317
+ bus: PreparedBus
318
+ boundaryDirection: FanoutDirection
319
+ traceWidth: number
320
+ clearance: number
321
+ }): number[] | undefined {
322
+ const { bus, boundaryDirection, traceWidth, clearance } = params
323
+ if (getCornerSide(bus) || !busUsesCoordinatedWindingChannel(bus))
324
+ return undefined
325
+ const layers = new Set(
326
+ bus.connections.map(
327
+ (connection) =>
328
+ connection.exitTargetPoint?.layer ??
329
+ getPointLayer(connection.targetPoint),
330
+ ),
331
+ )
332
+ if (layers.size < 2) return undefined
333
+ const minimum = isHorizontal(boundaryDirection)
334
+ ? bus.sharedBoundary.minY
335
+ : bus.sharedBoundary.minX
336
+ const maximum = isHorizontal(boundaryDirection)
337
+ ? bus.sharedBoundary.maxY
338
+ : bus.sharedBoundary.maxX
339
+ const tracks = bus.connections
340
+ .map((connection) =>
341
+ Math.max(
342
+ minimum,
343
+ Math.min(
344
+ maximum,
345
+ getPerpendicularAxis(
346
+ connection.exitTargetPoint ?? connection.targetPoint,
347
+ boundaryDirection,
348
+ ),
349
+ ),
350
+ ),
351
+ )
352
+ .toSorted((a, b) => a - b)
353
+ const pitch = traceWidth + clearance
354
+ if (
355
+ tracks.every(
356
+ (track, index) =>
357
+ index === 0 || track - tracks[index - 1]! >= pitch - 1e-9,
358
+ )
359
+ )
360
+ return undefined
361
+ if (maximum - minimum < (tracks.length - 1) * pitch - 1e-9) return undefined
362
+ // Pool neighboring overlaps while preserving their mean requested location.
363
+ // Subtracting the pitch reduces the clearance constraint to monotonicity.
364
+ const blocks: { start: number; count: number; mean: number }[] = []
365
+ for (const [index, track] of tracks.entries()) {
366
+ blocks.push({ start: index, count: 1, mean: track - index * pitch })
367
+ while (blocks.length > 1 && blocks.at(-2)!.mean > blocks.at(-1)!.mean) {
368
+ const second = blocks.pop()!
369
+ const first = blocks.pop()!
370
+ blocks.push({
371
+ start: first.start,
372
+ count: first.count + second.count,
373
+ mean:
374
+ (first.mean * first.count + second.mean * second.count) /
375
+ (first.count + second.count),
376
+ })
377
+ }
378
+ }
379
+ for (const block of blocks) {
380
+ const mean = Math.max(
381
+ minimum,
382
+ Math.min(maximum - (tracks.length - 1) * pitch, block.mean),
383
+ )
384
+ for (let index = block.start; index < block.start + block.count; index++)
385
+ tracks[index] = mean + index * pitch
386
+ }
387
+ return tracks
388
+ }
389
+
314
390
  export function getBoundaryTargetTrack(params: {
315
391
  bus: PreparedBus
316
392
  connection: PreparedConnection
317
393
  boundaryDirection: FanoutDirection
394
+ traceWidth?: number
395
+ clearance?: number
396
+ layerNames?: readonly string[]
397
+ targetLayer?: string
398
+ windingOrderIndex?: number
318
399
  }): number {
319
400
  const requestedTrack = getPerpendicularAxis(
320
401
  params.connection.exitTargetPoint ?? params.connection.targetPoint,
@@ -326,6 +407,22 @@ export function getBoundaryTargetTrack(params: {
326
407
  const boundaryMaximum = isHorizontal(params.boundaryDirection)
327
408
  ? params.bus.sharedBoundary.maxY
328
409
  : params.bus.sharedBoundary.maxX
410
+ const distributedTracks =
411
+ params.traceWidth !== undefined && params.clearance !== undefined
412
+ ? getDistributedBoundaryTargetTracks({
413
+ ...params,
414
+ traceWidth: params.traceWidth,
415
+ clearance: params.clearance,
416
+ })
417
+ : undefined
418
+ if (distributedTracks && params.layerNames && params.targetLayer) {
419
+ const { rank } = getWindingTargetRank({
420
+ ...params,
421
+ layerNames: params.layerNames,
422
+ targetLayer: params.targetLayer,
423
+ })
424
+ return distributedTracks[rank]!
425
+ }
329
426
  return Math.max(boundaryMinimum, Math.min(boundaryMaximum, requestedTrack))
330
427
  }
331
428
 
@@ -1102,6 +1199,10 @@ function buildPlan(params: {
1102
1199
  bus,
1103
1200
  connection: preparedConnection,
1104
1201
  boundaryDirection,
1202
+ traceWidth,
1203
+ clearance,
1204
+ layerNames,
1205
+ targetLayer,
1105
1206
  })
1106
1207
  : track
1107
1208
  const connectionRank = getConnectionRank(bus, preparedConnection)
@@ -2459,6 +2560,7 @@ export function* routeBusAlternativesSteps(
2459
2560
  stopAfterFirstRejectedViaMinimalCandidate = false,
2460
2561
  fixedViaPointsByConnectionIndex,
2461
2562
  reservedVias = [],
2563
+ softReservedVias = [],
2462
2564
  viaMinimalOnly = false,
2463
2565
  allowBoundarySideViaFallback = false,
2464
2566
  preferCornerBoundaryVia = false,
@@ -2608,14 +2710,21 @@ export function* routeBusAlternativesSteps(
2608
2710
  localDogboneRepair?: boolean
2609
2711
  }
2610
2712
  const maximumThroughAllRouteOrderAttempts = 24
2611
- const windingTargetOrderCount = cornerSide
2612
- ? getWindingTargetOrders({
2613
- bus,
2614
- boundaryDirection,
2615
- layerNames,
2616
- targetLayer,
2617
- }).orders.length
2618
- : 1
2713
+ const windingTargetOrderCount =
2714
+ cornerSide ||
2715
+ getDistributedBoundaryTargetTracks({
2716
+ bus,
2717
+ boundaryDirection,
2718
+ traceWidth,
2719
+ clearance,
2720
+ })
2721
+ ? getWindingTargetOrders({
2722
+ bus,
2723
+ boundaryDirection,
2724
+ layerNames,
2725
+ targetLayer,
2726
+ }).orders.length
2727
+ : 1
2619
2728
  const uniformDogboneTerminalPatterns: CoordinatedTerminalPattern[] =
2620
2729
  viaHandednesses.map((viaHandedness) => ({
2621
2730
  label: `uniform-${viaHandedness}`,
@@ -2760,7 +2869,13 @@ export function* routeBusAlternativesSteps(
2760
2869
  const coordinatedViaPoints =
2761
2870
  fixedViaPointsByConnectionIndex ??
2762
2871
  (!allowBlindAndBuriedVias &&
2763
- bus.connections.length >= 8 &&
2872
+ (bus.connections.length >= 8 ||
2873
+ getDistributedBoundaryTargetTracks({
2874
+ bus,
2875
+ boundaryDirection,
2876
+ traceWidth,
2877
+ clearance,
2878
+ })) &&
2764
2879
  reservedVias.length === 0
2765
2880
  ? matchComponentDogboneViaSites([bus], {
2766
2881
  viaDiameter,
@@ -2902,6 +3017,11 @@ export function* routeBusAlternativesSteps(
2902
3017
  bus,
2903
3018
  connection: preparedConnection,
2904
3019
  boundaryDirection,
3020
+ traceWidth,
3021
+ clearance,
3022
+ layerNames,
3023
+ targetLayer,
3024
+ windingOrderIndex: terminalPattern.windingOrderIndex,
2905
3025
  })
2906
3026
  return {
2907
3027
  connection: preparedConnection,
@@ -3003,6 +3123,7 @@ export function* routeBusAlternativesSteps(
3003
3123
  alignGridToPads,
3004
3124
  includeReverseTargetRotation: terminalPattern.localDogboneRepair,
3005
3125
  reservedVias,
3126
+ softReservedVias,
3006
3127
  gridStepDivisor,
3007
3128
  preferTargetDirectedLaneBias:
3008
3129
  terminalPattern.preferTargetDirectedLaneBias,
@@ -3060,9 +3181,9 @@ export function* routeBusAlternativesSteps(
3060
3181
  // A through-via does not have to sit next to the source pad. A narrow bus
3061
3182
  // embedded in another bus's source field can have every local dogbone site
3062
3183
  // occupied while still having a clear source-layer escape. A pair first
3063
- // relocates its two vias just outside the nearest package edge; a singleton
3064
- // retains the boundary-side-via fallback. Both forms preserve one via per
3065
- // signal without weakening any clearance rule.
3184
+ // relocates its two vias just outside the nearest package edge. A singleton
3185
+ // tries the existing boundary-side vias before searching outside the package.
3186
+ // Both forms preserve one via per signal without weakening clearance rules.
3066
3187
  if (
3067
3188
  alternatives.length === 0 &&
3068
3189
  allowBoundarySideViaFallback &&
@@ -3095,6 +3216,10 @@ export function* routeBusAlternativesSteps(
3095
3216
  bus,
3096
3217
  connection: preparedConnection,
3097
3218
  boundaryDirection,
3219
+ traceWidth,
3220
+ clearance,
3221
+ layerNames,
3222
+ targetLayer,
3098
3223
  }),
3099
3224
  )
3100
3225
  const finalExitPoints = finalTracks.map((track) =>
@@ -3182,7 +3307,64 @@ export function* routeBusAlternativesSteps(
3182
3307
  ),
3183
3308
  )
3184
3309
  : []
3310
+ const matchedVias = bus.connections.map(
3311
+ (connection) =>
3312
+ fixedViaPointsByConnectionIndex.get(connection.connectionIndex)!,
3313
+ )
3314
+ const packageEdgeViaCandidates = [
3315
+ {
3316
+ distance: sourceCenter.x - bus.componentBounds.minX,
3317
+ axis: "x" as const,
3318
+ value: bus.componentBounds.minX - 2 * insetStep,
3319
+ },
3320
+ {
3321
+ distance: bus.componentBounds.maxX - sourceCenter.x,
3322
+ axis: "x" as const,
3323
+ value: bus.componentBounds.maxX + 2 * insetStep,
3324
+ },
3325
+ {
3326
+ distance: sourceCenter.y - bus.componentBounds.minY,
3327
+ axis: "y" as const,
3328
+ value: bus.componentBounds.minY - 2 * insetStep,
3329
+ },
3330
+ {
3331
+ distance: bus.componentBounds.maxY - sourceCenter.y,
3332
+ axis: "y" as const,
3333
+ value: bus.componentBounds.maxY + 2 * insetStep,
3334
+ },
3335
+ ]
3336
+ .toSorted((a, b) => a.distance - b.distance)
3337
+ .flatMap(({ axis, value }) => {
3338
+ const otherAxis = axis === "x" ? "y" : "x"
3339
+ const mean =
3340
+ matchedVias.reduce((sum, via) => sum + via[otherAxis], 0) /
3341
+ matchedVias.length
3342
+ const order = matchedVias
3343
+ .map((via, index) => ({ index, track: via[otherAxis] }))
3344
+ .toSorted((a, b) => a.track - b.track || a.index - b.index)
3345
+ const pitch = viaDiameter + clearance
3346
+ const points = matchedVias.map((via, index) => ({
3347
+ ...via,
3348
+ [axis]: value,
3349
+ [otherAxis]:
3350
+ order.length === 2 &&
3351
+ Math.abs(order[1]!.track - order[0]!.track) < pitch
3352
+ ? mean + (order.findIndex((v) => v.index === index) - 0.5) * pitch
3353
+ : via[otherAxis],
3354
+ }))
3355
+ return points.length === 2
3356
+ ? [points, [points[1]!, points[0]!]]
3357
+ : [points]
3358
+ })
3359
+ const preferPackageEdgeVias =
3360
+ bus.connections.length === 2 && Boolean(getCornerSide(bus))
3185
3361
  const viaCandidates = [
3362
+ ...(preferPackageEdgeVias ? packageEdgeViaCandidates : []).map(
3363
+ (points) => ({
3364
+ points,
3365
+ boundarySide: false,
3366
+ }),
3367
+ ),
3186
3368
  ...displacedViaCandidates.map((points) => ({
3187
3369
  points,
3188
3370
  boundarySide: false,
@@ -3191,6 +3373,15 @@ export function* routeBusAlternativesSteps(
3191
3373
  points,
3192
3374
  boundarySide: true,
3193
3375
  })),
3376
+ // Preserve existing centered and singleton escapes before trying a short
3377
+ // source-layer route beyond the package. The target layer can then wind to the exit
3378
+ // without a local via being fenced in by an already-routed wide bus.
3379
+ ...(!preferPackageEdgeVias ? packageEdgeViaCandidates : []).map(
3380
+ (points) => ({
3381
+ points,
3382
+ boundarySide: false,
3383
+ }),
3384
+ ),
3194
3385
  ]
3195
3386
  for (const { points: boundaryViaPoints, boundarySide } of viaCandidates) {
3196
3387
  const boundaryVias = bus.connections.map((connection, index) => ({
@@ -3254,6 +3445,7 @@ export function* routeBusAlternativesSteps(
3254
3445
  allowBlindAndBuriedVias,
3255
3446
  allowSameNetMerges,
3256
3447
  maximumRouteOrderAttempts: bus.connections.length === 1 ? 3 : 6,
3448
+ softReservedVias,
3257
3449
  reservedVias:
3258
3450
  bus.connections.length > 1
3259
3451
  ? [...reservedVias, ...boundaryVias]
@@ -3303,6 +3495,7 @@ export function* routeBusAlternativesSteps(
3303
3495
  allowSameNetMerges,
3304
3496
  maximumRouteOrderAttempts: 6,
3305
3497
  reservedVias,
3498
+ softReservedVias,
3306
3499
  gridStepDivisor: 2,
3307
3500
  alignGridToPads: true,
3308
3501
  },
@@ -3355,7 +3548,9 @@ export function* routeBusAlternativesSteps(
3355
3548
  )
3356
3549
  return {
3357
3550
  ...sourceLayerPlan,
3358
- ...(preferCornerBoundaryVia || bus.connections.length > 1
3551
+ ...(preferCornerBoundaryVia ||
3552
+ bus.connections.length > 1 ||
3553
+ !boundarySide
3359
3554
  ? { sourceEscapeSegmentCount: sourceLayerPlan.segments.length }
3360
3555
  : {}),
3361
3556
  targetLayer,
@@ -62,6 +62,8 @@ export interface RouteViaMinimalWindingParams {
62
62
  allowSameNetMerges?: boolean
63
63
  maximumRouteOrderAttempts?: number
64
64
  reservedVias?: readonly ViaMinimalWindingReservedVia[]
65
+ /** Cost hints for provisional sites that the caller must rematch before commit. */
66
+ softReservedVias?: readonly ViaMinimalWindingReservedVia[]
65
67
  /** Use a finer uniform grid for narrow channels between reserved vias. */
66
68
  gridStepDivisor?: 1 | 2
67
69
  /** Bias bounded fixed-site searches toward the remote target band. */
@@ -637,6 +639,7 @@ export function* routeViaMinimalWindingAlternativesSteps(
637
639
  allowSameNetMerges = false,
638
640
  maximumRouteOrderAttempts,
639
641
  reservedVias = [],
642
+ softReservedVias = [],
640
643
  gridStepDivisor = 1,
641
644
  preferTargetDirectedLaneBias = false,
642
645
  allowSourceLayerRouting = false,
@@ -702,6 +705,40 @@ export function* routeViaMinimalWindingAlternativesSteps(
702
705
  point: { x: gridMinX + column * gridStep, y: gridMinY + row * gridStep },
703
706
  }
704
707
  })
708
+ // Provisional plane barrels guide search without being fixed obstacles.
709
+ // Touch only the small grid rectangles around each disk, not every node.
710
+ const softViaCosts = softReservedVias.length
711
+ ? new Float32Array(nodeCount)
712
+ : undefined
713
+ if (softViaCosts) {
714
+ for (const { via } of softReservedVias) {
715
+ if (!via.spanLayers.includes(targetLayer)) continue
716
+ const radius = via.diameter / 2 + traceWidth / 2 + clearance
717
+ const minimumColumn = Math.max(
718
+ 0,
719
+ Math.ceil((via.center.x - radius - gridMinX) / gridStep),
720
+ )
721
+ const maximumColumn = Math.min(
722
+ columnCount - 1,
723
+ Math.floor((via.center.x + radius - gridMinX) / gridStep),
724
+ )
725
+ const minimumRow = Math.max(
726
+ 0,
727
+ Math.ceil((via.center.y - radius - gridMinY) / gridStep),
728
+ )
729
+ const maximumRow = Math.min(
730
+ rowCount - 1,
731
+ Math.floor((via.center.y + radius - gridMinY) / gridStep),
732
+ )
733
+ for (let row = minimumRow; row <= maximumRow; row++) {
734
+ for (let column = minimumColumn; column <= maximumColumn; column++) {
735
+ const index = row * columnCount + column
736
+ if (distance(nodes[index]!.point, via.center) < radius)
737
+ softViaCosts[index] = softViaCosts[index]! + 25 * gridStep
738
+ }
739
+ }
740
+ }
741
+ }
705
742
  const sampledGridPoints = includeVisualization
706
743
  ? nodes
707
744
  .filter(
@@ -1247,7 +1284,8 @@ export function* routeViaMinimalWindingAlternativesSteps(
1247
1284
  ? gridStep * Math.SQRT2
1248
1285
  : gridStep) +
1249
1286
  (addsTurn ? gridStep * 0.2 : 0) +
1250
- lanePenalty
1287
+ lanePenalty +
1288
+ (softViaCosts?.[nextNode] ?? 0)
1251
1289
  const nextState = nextNode * 9 + directionIndex
1252
1290
  if (nextDistance >= distances[nextState]! - EPSILON) continue
1253
1291
  const edgeIndex = current.node * 8 + directionIndex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.58",
3
+ "version": "0.0.60",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",