@tscircuit/fanout-solver 0.0.48 → 0.0.49

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.
@@ -3,6 +3,7 @@ import type {
3
3
  SimpleRouteJson,
4
4
  SimplifiedPcbTrace,
5
5
  } from "@tscircuit/capacity-autorouter"
6
+ import type { GraphicsObject } from "graphics-debug"
6
7
  import { getCornerBandSide, getDirectionForExitEdge } from "./boundary-exit"
7
8
  import { createFanoutOutputIds } from "./fanout-output-ids"
8
9
  import {
@@ -29,6 +30,7 @@ import type {
29
30
  const EPSILON = 1e-7
30
31
  const MAX_GRID_NODE_COUNT = 120_000
31
32
  const MAX_EXPANDED_STATE_COUNT = 240_000
33
+ const EXPANDED_STATES_PER_STEP = 5_000
32
34
  const MAX_CONNECTOR_COUNT = 24
33
35
  const CONNECTOR_RADIUS_IN_STEPS = 3.25
34
36
 
@@ -64,6 +66,18 @@ export interface RouteViaMinimalWindingParams {
64
66
  preferTargetDirectedLaneBias?: boolean
65
67
  }
66
68
 
69
+ export interface RouteViaMinimalWindingProgress {
70
+ phase: "route-connection"
71
+ routeOrderAttempt: number
72
+ connectionIndex: number
73
+ connectionCount: number
74
+ connectionName: string
75
+ searchBatch: number
76
+ expandedStateCount: number
77
+ connectionComplete: boolean
78
+ visualization?: GraphicsObject
79
+ }
80
+
67
81
  interface GridNode {
68
82
  point: Point2D
69
83
  column: number
@@ -251,6 +265,12 @@ class MinHeap {
251
265
  return this.values.length
252
266
  }
253
267
 
268
+ sampleEntries(maximumCount: number): readonly HeapEntry[] {
269
+ if (this.values.length <= maximumCount) return this.values
270
+ const stride = Math.ceil(this.values.length / maximumCount)
271
+ return this.values.filter((_, index) => index % stride === 0)
272
+ }
273
+
254
274
  push(value: HeapEntry): void {
255
275
  this.values.push(value)
256
276
  let index = this.values.length - 1
@@ -577,10 +597,11 @@ function buildPlan(params: {
577
597
  }
578
598
  }
579
599
 
580
- export function routeViaMinimalWindingAlternatives(
600
+ export function* routeViaMinimalWindingAlternativesSteps(
581
601
  params: RouteViaMinimalWindingParams,
582
602
  maximumAlternatives = 1,
583
- ): FanoutRoutePlan[][] {
603
+ includeVisualization = false,
604
+ ): Generator<RouteViaMinimalWindingProgress, FanoutRoutePlan[][], void> {
584
605
  if (!Number.isInteger(maximumAlternatives) || maximumAlternatives < 1) {
585
606
  throw new Error(
586
607
  `FanoutSolver: maximum winding alternatives must be a positive integer, received ${maximumAlternatives}`,
@@ -647,6 +668,14 @@ export function routeViaMinimalWindingAlternatives(
647
668
  point: { x: minX + column * gridStep, y: minY + row * gridStep },
648
669
  }
649
670
  })
671
+ const sampledGridPoints = includeVisualization
672
+ ? nodes
673
+ .filter(
674
+ (_, index) =>
675
+ index % Math.max(1, Math.ceil(nodes.length / 1_000)) === 0,
676
+ )
677
+ .map((node) => node.point)
678
+ : []
650
679
  const targetLayerObstacles = srj.obstacles.filter((obstacle) =>
651
680
  obstacle.layers.includes(targetLayer),
652
681
  )
@@ -861,11 +890,129 @@ export function routeViaMinimalWindingAlternatives(
861
890
  .slice(0, MAX_CONNECTOR_COUNT)
862
891
  }
863
892
 
864
- const routeOne = (params: {
893
+ const visualizeSearch = (params: {
894
+ terminal: ViaMinimalWindingTerminal
895
+ acceptedAttemptSegments: readonly BlockingSegment[]
896
+ expandedPoints: readonly Point2D[]
897
+ frontierPoints: readonly Point2D[]
898
+ bestPath: readonly Point2D[] | null
899
+ expandedStateCount: number
900
+ searchBatch: number
901
+ connectionComplete: boolean
902
+ }): GraphicsObject => {
903
+ const {
904
+ terminal,
905
+ acceptedAttemptSegments,
906
+ expandedPoints,
907
+ frontierPoints,
908
+ bestPath,
909
+ expandedStateCount,
910
+ searchBatch,
911
+ connectionComplete,
912
+ } = params
913
+ return {
914
+ title: `Winding ${bus.busId}: ${terminal.connection.connection.name}`,
915
+ rects: [
916
+ {
917
+ center: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
918
+ width: maxX - minX,
919
+ height: maxY - minY,
920
+ fill: "rgba(0, 0, 0, 0)",
921
+ stroke: "rgba(14, 165, 233, 0.9)",
922
+ label: "A* search boundary",
923
+ },
924
+ ],
925
+ points: [
926
+ ...sampledGridPoints.map((point) => ({
927
+ ...point,
928
+ color: "rgba(148, 163, 184, 0.22)",
929
+ })),
930
+ ...frontierPoints.map((point) => ({
931
+ ...point,
932
+ color: "rgba(6, 182, 212, 0.75)",
933
+ label: "open frontier",
934
+ })),
935
+ ...expandedPoints.map((point) => ({
936
+ ...point,
937
+ color: "rgba(244, 63, 94, 0.8)",
938
+ label: "expanded in current step",
939
+ })),
940
+ {
941
+ ...terminal.connection.sourcePoint,
942
+ color: "#f97316",
943
+ label: `source: ${terminal.connection.connection.name}`,
944
+ },
945
+ {
946
+ ...terminal.exitPoint,
947
+ color: "#a855f7",
948
+ label: "target exit",
949
+ },
950
+ ],
951
+ circles: terminals.map((candidate) => ({
952
+ center: candidate.viaPoint,
953
+ radius: viaDiameter / 2,
954
+ fill:
955
+ candidate === terminal
956
+ ? "rgba(250, 204, 21, 0.85)"
957
+ : "rgba(250, 204, 21, 0.25)",
958
+ stroke: candidate === terminal ? "#ca8a04" : "#a16207",
959
+ label:
960
+ candidate === terminal
961
+ ? "active via"
962
+ : `reserved via: ${candidate.connection.connection.name}`,
963
+ })),
964
+ lines: [
965
+ ...acceptedAttemptSegments.map(({ segment, connectionName }) => ({
966
+ points: [segment.start, segment.end],
967
+ strokeColor: "rgba(34, 197, 94, 0.9)",
968
+ strokeWidth: Math.max(traceWidth, gridStep * 0.35),
969
+ label: `accepted: ${connectionName}`,
970
+ })),
971
+ {
972
+ points: [terminal.viaPoint, terminal.exitPoint],
973
+ strokeColor: "rgba(168, 85, 247, 0.45)",
974
+ strokeWidth: Math.max(traceWidth * 0.5, gridStep * 0.15),
975
+ strokeDash: [gridStep, gridStep],
976
+ label: "active via-to-exit search",
977
+ },
978
+ ...(bestPath
979
+ ? [
980
+ {
981
+ points: [...bestPath],
982
+ strokeColor: "#facc15",
983
+ strokeWidth: Math.max(traceWidth, gridStep * 0.45),
984
+ label: "best path so far",
985
+ },
986
+ ]
987
+ : []),
988
+ ],
989
+ texts: [
990
+ {
991
+ x: minX,
992
+ y: maxY + gridStep * 2,
993
+ text: connectionComplete
994
+ ? `connection complete · ${expandedStateCount.toLocaleString()} states`
995
+ : `A* batch ${searchBatch} · ${expandedStateCount.toLocaleString()} states`,
996
+ color: "#0f172a",
997
+ fontSize: Math.max(gridStep * 2.5, 0.5),
998
+ anchorSide: "bottom_left",
999
+ },
1000
+ ],
1001
+ }
1002
+ }
1003
+
1004
+ const routeOneSteps = function* (params: {
865
1005
  terminal: ViaMinimalWindingTerminal
866
1006
  acceptedAttemptSegments: BlockingSegment[]
867
1007
  laneBias: -1 | 0 | 1
868
- }): Point2D[] | null => {
1008
+ }): Generator<
1009
+ {
1010
+ expandedStateCount: number
1011
+ visualization?: GraphicsObject
1012
+ },
1013
+ { points: Point2D[] | null; expandedStateCount: number },
1014
+ void
1015
+ > {
869
1016
  const { terminal, acceptedAttemptSegments, laneBias } = params
870
1017
  const starts = connectorCandidates({
871
1018
  terminal,
@@ -877,7 +1024,9 @@ export function routeViaMinimalWindingAlternatives(
877
1024
  endpoint: terminal.exitPoint,
878
1025
  acceptedAttemptSegments,
879
1026
  })
880
- if (starts.length === 0 || ends.length === 0) return null
1027
+ if (starts.length === 0 || ends.length === 0) {
1028
+ return { points: null, expandedStateCount: 0 }
1029
+ }
881
1030
  const endByNode = new Map<number, ConnectorCandidate[]>()
882
1031
  for (const end of ends) {
883
1032
  const values = endByNode.get(end.nodeIndex) ?? []
@@ -927,6 +1076,9 @@ export function routeViaMinimalWindingAlternatives(
927
1076
  let bestGoalCost = Number.POSITIVE_INFINITY
928
1077
  let bestGoalPoints: Point2D[] | null = null
929
1078
  let expandedStateCount = 0
1079
+ let expandedStatesSinceYield = 0
1080
+ let searchBatch = 0
1081
+ let expandedBatchPoints: Point2D[] = []
930
1082
  while (heap.size > 0 && expandedStateCount < MAX_EXPANDED_STATE_COUNT) {
931
1083
  const current = heap.pop()!
932
1084
  if (current.score >= bestGoalCost - EPSILON) break
@@ -938,6 +1090,10 @@ export function routeViaMinimalWindingAlternatives(
938
1090
  )
939
1091
  continue
940
1092
  expandedStateCount++
1093
+ expandedStatesSinceYield++
1094
+ if (includeVisualization && expandedStatesSinceYield % 50 === 0) {
1095
+ expandedBatchPoints.push(nodes[current.node]!.point)
1096
+ }
941
1097
  const endConnectors = endByNode.get(current.node)
942
1098
  if (endConnectors) {
943
1099
  const gridPoints: Point2D[] = []
@@ -1054,8 +1210,32 @@ export function routeViaMinimalWindingAlternatives(
1054
1210
  score: nextDistance + remaining,
1055
1211
  })
1056
1212
  }
1213
+ if (expandedStatesSinceYield >= EXPANDED_STATES_PER_STEP) {
1214
+ expandedStatesSinceYield = 0
1215
+ searchBatch++
1216
+ yield {
1217
+ expandedStateCount,
1218
+ ...(includeVisualization
1219
+ ? {
1220
+ visualization: visualizeSearch({
1221
+ terminal,
1222
+ acceptedAttemptSegments,
1223
+ expandedPoints: expandedBatchPoints,
1224
+ frontierPoints: heap
1225
+ .sampleEntries(120)
1226
+ .map((entry) => nodes[entry.node]!.point),
1227
+ bestPath: bestGoalPoints,
1228
+ expandedStateCount,
1229
+ searchBatch,
1230
+ connectionComplete: false,
1231
+ }),
1232
+ }
1233
+ : {}),
1234
+ }
1235
+ expandedBatchPoints = []
1236
+ }
1057
1237
  }
1058
- return bestGoalPoints
1238
+ return { points: bestGoalPoints, expandedStateCount }
1059
1239
  }
1060
1240
 
1061
1241
  const targetOrderedTerminals = terminals.toSorted((first, second) => {
@@ -1176,12 +1356,64 @@ export function routeViaMinimalWindingAlternatives(
1176
1356
  const acceptedAttemptSegments: BlockingSegment[] = []
1177
1357
  const routedPointsByConnectionName = new Map<string, Point2D[]>()
1178
1358
  let failed = false
1179
- for (const terminal of routeOrder) {
1180
- const points = routeOne({
1359
+ for (
1360
+ let terminalIndex = 0;
1361
+ terminalIndex < routeOrder.length;
1362
+ terminalIndex++
1363
+ ) {
1364
+ const terminal = routeOrder[terminalIndex]!
1365
+ const connectionSteps = routeOneSteps({
1181
1366
  terminal,
1182
1367
  acceptedAttemptSegments,
1183
1368
  laneBias,
1184
1369
  })
1370
+ let connectionResult = connectionSteps.next()
1371
+ let searchBatch = 0
1372
+ let expandedStateCount = 0
1373
+ while (!connectionResult.done) {
1374
+ expandedStateCount = connectionResult.value.expandedStateCount
1375
+ yield {
1376
+ phase: "route-connection",
1377
+ routeOrderAttempt: routeOrderAttemptCount,
1378
+ connectionIndex: terminalIndex,
1379
+ connectionCount: routeOrder.length,
1380
+ connectionName: terminal.connection.connection.name,
1381
+ searchBatch: ++searchBatch,
1382
+ expandedStateCount,
1383
+ connectionComplete: false,
1384
+ ...(connectionResult.value.visualization
1385
+ ? { visualization: connectionResult.value.visualization }
1386
+ : {}),
1387
+ }
1388
+ connectionResult = connectionSteps.next()
1389
+ }
1390
+ const { points, expandedStateCount: finalExpandedStateCount } =
1391
+ connectionResult.value
1392
+ expandedStateCount = finalExpandedStateCount
1393
+ yield {
1394
+ phase: "route-connection",
1395
+ routeOrderAttempt: routeOrderAttemptCount,
1396
+ connectionIndex: terminalIndex,
1397
+ connectionCount: routeOrder.length,
1398
+ connectionName: terminal.connection.connection.name,
1399
+ searchBatch,
1400
+ expandedStateCount,
1401
+ connectionComplete: true,
1402
+ ...(includeVisualization
1403
+ ? {
1404
+ visualization: visualizeSearch({
1405
+ terminal,
1406
+ acceptedAttemptSegments,
1407
+ expandedPoints: [],
1408
+ frontierPoints: [],
1409
+ bestPath: points,
1410
+ expandedStateCount,
1411
+ searchBatch,
1412
+ connectionComplete: true,
1413
+ }),
1414
+ }
1415
+ : {}),
1416
+ }
1185
1417
  if (!points) {
1186
1418
  failed = true
1187
1419
  break
@@ -1238,6 +1470,19 @@ export function routeViaMinimalWindingAlternatives(
1238
1470
  return alternatives
1239
1471
  }
1240
1472
 
1473
+ export function routeViaMinimalWindingAlternatives(
1474
+ params: RouteViaMinimalWindingParams,
1475
+ maximumAlternatives = 1,
1476
+ ): FanoutRoutePlan[][] {
1477
+ const steps = routeViaMinimalWindingAlternativesSteps(
1478
+ params,
1479
+ maximumAlternatives,
1480
+ )
1481
+ let result = steps.next()
1482
+ while (!result.done) result = steps.next()
1483
+ return result.value
1484
+ }
1485
+
1241
1486
  export function routeViaMinimalWinding(
1242
1487
  params: RouteViaMinimalWindingParams,
1243
1488
  ): FanoutRoutePlan[] | null {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.48",
3
+ "version": "0.0.49",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",