@tscircuit/fanout-solver 0.0.27 → 0.0.29

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
@@ -83,10 +83,11 @@ and treats each bus-layer decision atomically.
83
83
  - `completeOriginalEndpoints` adds a bounded fail-first completion stage after
84
84
  fanout. It first places DRC-gated interstitial capacitor escapes, then tries
85
85
  breakout-to-pad routes with layer transitions at interior points along the
86
- existing fanout copper, and finally a bounded capacity-router fallback. Vias
87
- at original or moved routing endpoints are rejected. A candidate is retained
88
- only when it improves independently proven original endpoint connectivity
89
- and the complete emitted copper remains DRC-clean.
86
+ existing fanout copper, and finally calls the optional
87
+ `routeDownstreamConnections` host callback. Vias at original or moved routing
88
+ endpoints are rejected. A candidate is retained only when it improves
89
+ independently proven original endpoint connectivity and the complete emitted
90
+ copper remains DRC-clean.
90
91
  - Emits supplied fanout traces, via obstacles, and moved breakout endpoints in a
91
92
  new `SimpleRouteJson`. The returned problem is ready for a downstream
92
93
  autorouter to finish.
@@ -102,7 +103,10 @@ bun add https://github.com/tscircuit/fanout-solver
102
103
  ## Usage
103
104
 
104
105
  ```ts
105
- import { CapacityMeshSolver } from "@tscircuit/capacity-autorouter"
106
+ import {
107
+ AutoroutingPipelineSolver6,
108
+ CapacityMeshSolver,
109
+ } from "@tscircuit/capacity-autorouter"
106
110
  import { FanoutSolver } from "@tscircuit/fanout-solver"
107
111
 
108
112
  const fanoutSolver = new FanoutSolver(simpleRouteJson, {
@@ -125,6 +129,17 @@ const fanoutSolver = new FanoutSolver(simpleRouteJson, {
125
129
  availableCornersAndSides: ["top_left", "top", "top_right"],
126
130
  borderDistribution: "even",
127
131
  compactBusTracks: true,
132
+ completeOriginalEndpoints: true,
133
+ routeDownstreamConnections: (inputSrj, { effort }) => {
134
+ const downstreamSolver = new AutoroutingPipelineSolver6(inputSrj, {
135
+ effort,
136
+ })
137
+ downstreamSolver.solve()
138
+ if (!downstreamSolver.solved) {
139
+ throw new Error(downstreamSolver.error ?? "Downstream routing failed")
140
+ }
141
+ return downstreamSolver.getOutputSimpleRouteJson().traces ?? []
142
+ },
128
143
  buses: [
129
144
  {
130
145
  busId: "ground",
@@ -146,6 +161,11 @@ const autorouter = new CapacityMeshSolver(
146
161
  autorouter.solve()
147
162
  ```
148
163
 
164
+ The downstream callback is optional. It lets the application choose its
165
+ board-level router while keeping `@tscircuit/fanout-solver` free of a runtime
166
+ autorouter import. Returned traces are still accepted only after the fanout
167
+ solver's connectivity and copper-clearance checks pass.
168
+
149
169
  The canonical bus input is the current `SimpleRouteJson` bus structure:
150
170
 
151
171
  ```ts
@@ -1,9 +1,8 @@
1
- import {
2
- AutoroutingPipelineSolver6,
3
- type ConnectionPoint,
4
- type Obstacle,
5
- type SimpleRouteJson,
6
- type SimplifiedPcbTrace,
1
+ import type {
2
+ ConnectionPoint,
3
+ Obstacle,
4
+ SimpleRouteJson,
5
+ SimplifiedPcbTrace,
7
6
  } from "@tscircuit/capacity-autorouter"
8
7
  import { createFanoutCompletionTraceId } from "./fanout-output-ids"
9
8
  import {
@@ -15,6 +14,7 @@ import { getCopperLayerNames, getLayerSpan } from "./layer-names"
15
14
  import { obstacleSharesElectricalNet } from "./net-identity"
16
15
  import type {
17
16
  FanoutEndpointCompletionReport,
17
+ FanoutDownstreamRouter,
18
18
  FanoutRoutePlan,
19
19
  Point2D,
20
20
  } from "./types"
@@ -921,8 +921,9 @@ function findDownstreamTerminalBranch(params: {
921
921
 
922
922
  /**
923
923
  * Connects short opposite-layer terminal pairs with constrained interstitial
924
- * vias, then delegates the remaining long routes to the capacity autorouter.
925
- * Only metric-improving, independently DRC-clean physical copper is retained.
924
+ * vias, then delegates remaining long routes to an optional host-provided
925
+ * router. Only metric-improving, independently DRC-clean physical copper is
926
+ * retained.
926
927
  */
927
928
  export function completeOriginalEndpoints(params: {
928
929
  inputSrj: SimpleRouteJson
@@ -933,6 +934,7 @@ export function completeOriginalEndpoints(params: {
933
934
  viaHoleDiameter: number
934
935
  clearance: number
935
936
  effort?: number
937
+ routeDownstreamConnections?: FanoutDownstreamRouter
936
938
  }): CompleteOriginalEndpointsResult {
937
939
  const {
938
940
  inputSrj,
@@ -943,6 +945,7 @@ export function completeOriginalEndpoints(params: {
943
945
  viaHoleDiameter,
944
946
  clearance,
945
947
  effort = 1,
948
+ routeDownstreamConnections,
946
949
  } = params
947
950
  const errors: string[] = []
948
951
  const baselineDrc = validateRoutedCopperDrc({
@@ -1072,7 +1075,8 @@ export function completeOriginalEndpoints(params: {
1072
1075
  if (
1073
1076
  baselineDrc.valid &&
1074
1077
  downstreamConnections.length > 0 &&
1075
- downstreamConnections.length <= 12
1078
+ downstreamConnections.length <= 12 &&
1079
+ routeDownstreamConnections
1076
1080
  ) {
1077
1081
  const downstreamConnectionNames = new Set(
1078
1082
  downstreamConnections.map((connection) => connection.name),
@@ -1095,36 +1099,32 @@ export function completeOriginalEndpoints(params: {
1095
1099
  traces: [],
1096
1100
  }
1097
1101
  try {
1098
- const downstreamSolver = new AutoroutingPipelineSolver6(downstreamInput, {
1099
- effort,
1102
+ const candidates = routeDownstreamConnections(downstreamInput, { effort })
1103
+ downstreamTraces = acceptDownstreamTraces({
1104
+ inputSrj,
1105
+ fanoutSrj,
1106
+ localTraces: [...bestLocalAttempt.traces, ...directDownstreamTraces],
1107
+ candidates: candidates.filter((trace) =>
1108
+ downstreamConnectionNames.has(trace.connection_name),
1109
+ ),
1110
+ clearance,
1100
1111
  })
1101
- downstreamSolver.solve()
1102
- if (downstreamSolver.solved) {
1103
- downstreamTraces = acceptDownstreamTraces({
1104
- inputSrj,
1105
- fanoutSrj,
1106
- localTraces: [...bestLocalAttempt.traces, ...directDownstreamTraces],
1107
- candidates:
1108
- downstreamSolver
1109
- .getOutputSimpleRouteJson()
1110
- .traces?.filter((trace) =>
1111
- downstreamConnectionNames.has(trace.connection_name),
1112
- ) ?? [],
1113
- clearance,
1114
- })
1115
- } else {
1116
- errors.push(
1117
- `Downstream autorouter did not solve: ${downstreamSolver.error ?? "unknown error"}`,
1118
- )
1119
- }
1120
1112
  } catch (error) {
1121
1113
  errors.push(
1122
- `Downstream autorouter failed: ${error instanceof Error ? error.message : String(error)}`,
1114
+ `Downstream router failed: ${error instanceof Error ? error.message : String(error)}`,
1123
1115
  )
1124
1116
  }
1117
+ } else if (!baselineDrc.valid && downstreamConnections.length > 0) {
1118
+ errors.push(
1119
+ "Skipped downstream router because the endpoint-completion baseline failed emitted-copper DRC",
1120
+ )
1125
1121
  } else if (downstreamConnections.length > 12) {
1126
1122
  errors.push(
1127
- `Skipped downstream autorouter for ${downstreamConnections.length} unresolved connections (bounded at 12)`,
1123
+ `Skipped downstream router for ${downstreamConnections.length} unresolved connections (bounded at 12)`,
1124
+ )
1125
+ } else if (downstreamConnections.length > 0 && !routeDownstreamConnections) {
1126
+ errors.push(
1127
+ `Skipped downstream router for ${downstreamConnections.length} unresolved connections because no routeDownstreamConnections callback was provided`,
1128
1128
  )
1129
1129
  }
1130
1130
 
@@ -1,7 +1,4 @@
1
- import {
2
- convertSrjToGraphicsObject,
3
- type SimpleRouteJson,
4
- } from "@tscircuit/capacity-autorouter"
1
+ import type { SimpleRouteJson } from "@tscircuit/capacity-autorouter"
5
2
  import { BaseSolver } from "@tscircuit/solver-utils"
6
3
  import type { GraphicsObject } from "graphics-debug"
7
4
  import { buildOutputSimpleRouteJson } from "./build-output"
@@ -9,7 +6,6 @@ import {
9
6
  completeOriginalEndpoints,
10
7
  type CompleteOriginalEndpointsResult,
11
8
  } from "./complete-original-endpoints"
12
- import { getCopperLayerColor } from "./layer-colors"
13
9
  import { generateLayerAssignments, getCopperLayerNames } from "./layer-names"
14
10
  import {
15
11
  prepareFanoutBuses,
@@ -23,6 +19,7 @@ import {
23
19
  import { routeSingleLayerWithAdaptiveExits } from "./route-single-layer-adaptive-exits"
24
20
  import { routeSingleLayerWithPushAndShove } from "./route-single-layer-push-shove"
25
21
  import { validateFanoutSolution } from "./validate-fanout-solution"
22
+ import { visualizeSimpleRouteJson } from "./visualize-simple-route-json"
26
23
  import type {
27
24
  AssignmentAttempt,
28
25
  Bounds,
@@ -489,6 +486,7 @@ export class FanoutSolver extends BaseSolver {
489
486
  viaHoleDiameter: this.config.viaHoleDiameter,
490
487
  clearance: this.config.clearance,
491
488
  effort: this.options.endpointCompletionEffort,
489
+ routeDownstreamConnections: this.options.routeDownstreamConnections,
492
490
  })
493
491
  }
494
492
 
@@ -1254,46 +1252,6 @@ export class FanoutSolver extends BaseSolver {
1254
1252
  this.endpointCompletion?.simpleRouteJson ??
1255
1253
  this.bestAttempt?.outputSrj ??
1256
1254
  this.inputSrj
1257
- const graphics = convertSrjToGraphicsObject(visualizedSrj)
1258
- const circularPadKeys = new Set(
1259
- visualizedSrj.obstacles
1260
- .filter(
1261
- (obstacle) =>
1262
- (obstacle as typeof obstacle & { shape?: string }).shape ===
1263
- "circle",
1264
- )
1265
- .map(
1266
- (obstacle) =>
1267
- `${obstacle.center.x}:${obstacle.center.y}:${obstacle.width}:${obstacle.height}`,
1268
- ),
1269
- )
1270
- const circularPadGraphics: NonNullable<GraphicsObject["circles"]> = []
1271
- const rects = graphics.rects?.filter((rect) => {
1272
- const key = `${rect.center.x}:${rect.center.y}:${rect.width}:${rect.height}`
1273
- if (!circularPadKeys.has(key)) return true
1274
- circularPadGraphics.push({
1275
- center: rect.center,
1276
- radius: Math.min(rect.width, rect.height) / 2,
1277
- fill: rect.fill,
1278
- stroke: rect.stroke,
1279
- layer: rect.layer,
1280
- label: rect.label,
1281
- })
1282
- return false
1283
- })
1284
- return {
1285
- ...graphics,
1286
- rects,
1287
- circles: [...(graphics.circles ?? []), ...circularPadGraphics],
1288
- lines: graphics.lines?.map((line) => {
1289
- const layerMatch = /^z(\d+)$/.exec(line.layer ?? "")
1290
- if (!layerMatch) return line
1291
- const { strokeDash: _strokeDash, ...solidLine } = line
1292
- return {
1293
- ...solidLine,
1294
- strokeColor: getCopperLayerColor(Number(layerMatch[1])),
1295
- }
1296
- }),
1297
- }
1255
+ return visualizeSimpleRouteJson(visualizedSrj)
1298
1256
  }
1299
1257
  }
package/lib/index.ts CHANGED
@@ -26,6 +26,8 @@ export type {
26
26
  FanoutBusTermination,
27
27
  FanoutCorner,
28
28
  FanoutDirection,
29
+ FanoutDownstreamRouter,
30
+ FanoutDownstreamRouterOptions,
29
31
  FanoutEdge,
30
32
  FanoutEndpointCompletionReport,
31
33
  FanoutPlaneConnectivity,
package/lib/route-bus.ts CHANGED
@@ -293,11 +293,23 @@ function getTrackCandidates(params: {
293
293
  function getPreferredTrack(params: {
294
294
  bus: PreparedBus
295
295
  connection: PreparedConnection
296
+ traceWidth: number
296
297
  }): number {
297
- return getPerpendicularAxis(
298
+ const preferredTrack = getPerpendicularAxis(
298
299
  params.connection.exitTargetPoint ?? params.connection.targetPoint,
299
300
  params.bus.direction,
300
301
  )
302
+ const boundaryMinimum = isHorizontal(params.bus.direction)
303
+ ? params.bus.sharedBoundary.minY
304
+ : params.bus.sharedBoundary.minX
305
+ const boundaryMaximum = isHorizontal(params.bus.direction)
306
+ ? params.bus.sharedBoundary.maxY
307
+ : params.bus.sharedBoundary.maxX
308
+
309
+ return Math.max(
310
+ boundaryMinimum + params.traceWidth / 2,
311
+ Math.min(boundaryMaximum - params.traceWidth / 2, preferredTrack),
312
+ )
301
313
  }
302
314
 
303
315
  function getLegacyPreferredTrack(params: {
@@ -1373,6 +1385,7 @@ export function routeBusAlternatives(
1373
1385
  getPreferredTrack({
1374
1386
  bus,
1375
1387
  connection: preparedConnection,
1388
+ traceWidth,
1376
1389
  }),
1377
1390
  getLegacyPreferredTrack({
1378
1391
  bus,
package/lib/types.ts CHANGED
@@ -49,6 +49,19 @@ export type FanoutAvailableCornerAndSideInput =
49
49
 
50
50
  export type FanoutBorderDistribution = "preserve" | "even"
51
51
 
52
+ export interface FanoutDownstreamRouterOptions {
53
+ effort: number
54
+ }
55
+
56
+ /**
57
+ * Optional host-provided router for unresolved connections after the fanout
58
+ * solver's local endpoint-completion passes.
59
+ */
60
+ export type FanoutDownstreamRouter = (
61
+ inputSrj: SimpleRouteJson,
62
+ options: FanoutDownstreamRouterOptions,
63
+ ) => SimplifiedPcbTrace[]
64
+
52
65
  export type FanoutBusTermination =
53
66
  | {
54
67
  type: "boundary"
@@ -127,8 +140,13 @@ export interface FanoutSolverOptions {
127
140
  * with the independent endpoint-connectivity and emitted-copper validators.
128
141
  */
129
142
  completeOriginalEndpoints?: boolean
130
- /** Effort passed to the bounded downstream capacity-router pass. */
143
+ /** Effort passed to the optional bounded downstream-router pass. */
131
144
  endpointCompletionEffort?: number
145
+ /**
146
+ * Host-provided fallback for unresolved endpoint connections. The fanout
147
+ * package deliberately does not import a board-level autorouter at runtime.
148
+ */
149
+ routeDownstreamConnections?: FanoutDownstreamRouter
132
150
  }
133
151
 
134
152
  export interface FanoutAttemptSummary {
@@ -0,0 +1,453 @@
1
+ import type {
2
+ ConnectionPoint,
3
+ Obstacle,
4
+ SimpleRouteJson,
5
+ } from "@tscircuit/capacity-autorouter"
6
+ import type { Circle, GraphicsObject, Line, Point, Rect } from "graphics-debug"
7
+ import { getCopperLayerColor } from "./layer-colors"
8
+ import { getCopperLayerNames } from "./layer-names"
9
+
10
+ const LEGACY_VISUALIZATION_LAYERS = new Set([
11
+ "top",
12
+ "bottom",
13
+ "inner1",
14
+ "inner2",
15
+ "inner3",
16
+ "inner4",
17
+ "inner5",
18
+ "inner6",
19
+ "inner7",
20
+ "inner8",
21
+ ])
22
+
23
+ const JUMPER_DIMENSIONS = {
24
+ "0603": { padLength: 0.8, padWidth: 0.95 },
25
+ "1206": { padLength: 0.6, padWidth: 1.6 },
26
+ "1206x4_pair": { padLength: 0.8, padWidth: 0.5 },
27
+ } as const
28
+
29
+ const getLayerIndex = (layerNames: string[], layerName: string): number => {
30
+ const layerIndex = layerNames.indexOf(layerName)
31
+ if (layerIndex < 0) {
32
+ throw new Error(
33
+ `FanoutSolver: cannot visualize unknown copper layer "${layerName}"`,
34
+ )
35
+ }
36
+ return layerIndex
37
+ }
38
+
39
+ const getGraphicsLayer = (
40
+ layerNames: string[],
41
+ copperLayers: readonly string[],
42
+ ): string => {
43
+ const zLayers = [
44
+ ...new Set(
45
+ copperLayers.map((layerName) => getLayerIndex(layerNames, layerName)),
46
+ ),
47
+ ].sort((first, second) => first - second)
48
+ return `z${zLayers.join(",")}`
49
+ }
50
+
51
+ const getPointLayers = (point: ConnectionPoint): string[] => {
52
+ const layers = "layers" in point ? point.layers : undefined
53
+ if (layers && layers.length > 0) return layers
54
+ return [(point as ConnectionPoint & { layer: string }).layer]
55
+ }
56
+
57
+ const getObstacleLayerIndexes = (
58
+ obstacle: Obstacle,
59
+ layerNames: string[],
60
+ ): number[] => {
61
+ if (obstacle.__zLayers && obstacle.__zLayers.length > 0) {
62
+ return [...new Set(obstacle.__zLayers)]
63
+ .filter(
64
+ (layerIndex) =>
65
+ Number.isInteger(layerIndex) &&
66
+ layerIndex >= 0 &&
67
+ layerIndex < layerNames.length,
68
+ )
69
+ .sort((first, second) => first - second)
70
+ }
71
+ return [
72
+ ...new Set(
73
+ obstacle.layers.map((layerName) => getLayerIndex(layerNames, layerName)),
74
+ ),
75
+ ].sort((first, second) => first - second)
76
+ }
77
+
78
+ const getViaLayerNames = (
79
+ layerNames: string[],
80
+ fromLayer: string,
81
+ toLayer: string,
82
+ ): string[] => {
83
+ const fromIndex = getLayerIndex(layerNames, fromLayer)
84
+ const toIndex = getLayerIndex(layerNames, toLayer)
85
+ return layerNames.slice(
86
+ Math.min(fromIndex, toIndex),
87
+ Math.max(fromIndex, toIndex) + 1,
88
+ )
89
+ }
90
+
91
+ const firstFiniteNumber = (
92
+ ...values: Array<number | undefined>
93
+ ): number | undefined =>
94
+ values.find((value) => typeof value === "number" && Number.isFinite(value))
95
+
96
+ const getViaPadDiameter = (srj: SimpleRouteJson): number => {
97
+ const holeDiameter = firstFiniteNumber(
98
+ srj.min_via_hole_diameter,
99
+ srj.minViaHoleDiameter,
100
+ )
101
+ const padDiameter = firstFiniteNumber(
102
+ srj.min_via_pad_diameter,
103
+ srj.minViaPadDiameter,
104
+ srj.minViaDiameter,
105
+ )
106
+ return Math.max(padDiameter ?? srj.minViaDiameter ?? 0.3, holeDiameter ?? 0)
107
+ }
108
+
109
+ const getColorMap = (
110
+ connections: SimpleRouteJson["connections"],
111
+ ): Record<string, string> =>
112
+ Object.fromEntries(
113
+ connections.map((connection, index) => [
114
+ connection.name,
115
+ `hsl(${(index * 340) / connections.length}, 100%, 50%)`,
116
+ ]),
117
+ )
118
+
119
+ const hslToRgb = (hue: number, saturation: number, lightness: number) => {
120
+ const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation
121
+ const hueSegment = (((hue % 360) + 360) % 360) / 60
122
+ const secondary = chroma * (1 - Math.abs((hueSegment % 2) - 1))
123
+ const [red, green, blue] =
124
+ hueSegment < 1
125
+ ? [chroma, secondary, 0]
126
+ : hueSegment < 2
127
+ ? [secondary, chroma, 0]
128
+ : hueSegment < 3
129
+ ? [0, chroma, secondary]
130
+ : hueSegment < 4
131
+ ? [0, secondary, chroma]
132
+ : hueSegment < 5
133
+ ? [secondary, 0, chroma]
134
+ : [chroma, 0, secondary]
135
+ const match = lightness - chroma / 2
136
+ return [red, green, blue].map((channel) =>
137
+ Math.round((channel + match) * 255),
138
+ )
139
+ }
140
+
141
+ const transparentize = (color: string, amount: number): string => {
142
+ const namedColors: Record<string, [number, number, number]> = {
143
+ blue: [0, 0, 255],
144
+ orange: [255, 165, 0],
145
+ purple: [128, 0, 128],
146
+ red: [255, 0, 0],
147
+ }
148
+ let channels = namedColors[color]
149
+ let alpha = 1
150
+ const rgbaMatch =
151
+ /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/.exec(
152
+ color,
153
+ )
154
+ if (rgbaMatch) {
155
+ channels = [
156
+ Number(rgbaMatch[1]),
157
+ Number(rgbaMatch[2]),
158
+ Number(rgbaMatch[3]),
159
+ ]
160
+ alpha = rgbaMatch[4] === undefined ? 1 : Number(rgbaMatch[4])
161
+ }
162
+ const hslMatch =
163
+ /^hsl\(\s*([\d.-]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/.exec(color)
164
+ if (hslMatch) {
165
+ channels = hslToRgb(
166
+ Number(hslMatch[1]),
167
+ Number(hslMatch[2]) / 100,
168
+ Number(hslMatch[3]) / 100,
169
+ ) as [number, number, number]
170
+ }
171
+ if (!channels) return color
172
+ const outputAlpha = +Math.max(0, alpha * 100 - amount * 100).toFixed(2) / 100
173
+ if (outputAlpha >= 1) {
174
+ const hex = channels
175
+ .map((channel) => Math.round(channel).toString(16).padStart(2, "0"))
176
+ .join("")
177
+ return hex[0] === hex[1] && hex[2] === hex[3] && hex[4] === hex[5]
178
+ ? `#${hex[0]}${hex[2]}${hex[4]}`
179
+ : `#${hex}`
180
+ }
181
+ return `rgba(${channels.join(",")},${outputAlpha})`
182
+ }
183
+
184
+ const getUniqueValues = (values: readonly string[]): string[] => {
185
+ const seen = new Set<string>()
186
+ return values.filter((value) => {
187
+ if (seen.has(value)) return false
188
+ seen.add(value)
189
+ return true
190
+ })
191
+ }
192
+
193
+ const createObstacleLabelFormatter = (srj: SimpleRouteJson) => {
194
+ const rootConnectionIndex = new Map<string, string[]>()
195
+ const addMapping = (identifier: string | undefined, rootName: string) => {
196
+ if (!identifier) return
197
+ const names = rootConnectionIndex.get(identifier) ?? []
198
+ if (!names.includes(rootName)) names.push(rootName)
199
+ rootConnectionIndex.set(identifier, names)
200
+ }
201
+ for (const connection of srj.connections) {
202
+ const rootNames = connection.__rootConnectionNames ?? [connection.name]
203
+ for (const rootName of rootNames) {
204
+ addMapping(connection.name, rootName)
205
+ addMapping(rootName, rootName)
206
+ addMapping(connection.__netConnectionName, rootName)
207
+ for (const point of connection.pointsToConnect) {
208
+ addMapping(point.pointId, rootName)
209
+ addMapping(point.pcb_port_id, rootName)
210
+ }
211
+ }
212
+ }
213
+ return (obstacle: Obstacle): string => {
214
+ const rootNames = getUniqueValues([
215
+ ...obstacle.connectedTo.flatMap(
216
+ (identifier) => rootConnectionIndex.get(identifier) ?? [],
217
+ ),
218
+ ...(obstacle.offBoardConnectsTo ?? []).flatMap(
219
+ (identifier) => rootConnectionIndex.get(identifier) ?? [],
220
+ ),
221
+ ])
222
+ const rootLabel = rootNames.join(", ")
223
+ return obstacle.layers
224
+ .map((layerName) =>
225
+ rootLabel ? `${layerName}\n${rootLabel}` : layerName,
226
+ )
227
+ .join("\n")
228
+ }
229
+ }
230
+
231
+ export function visualizeSimpleRouteJson(srj: SimpleRouteJson): GraphicsObject {
232
+ const layerNames = getCopperLayerNames(srj.layerCount)
233
+ const hasArbitraryCopperLayer = (srj.traces ?? []).some((trace) =>
234
+ trace.route.some((routePoint, routePointIndex) => {
235
+ const nextRoutePoint = trace.route[routePointIndex + 1]
236
+ return (
237
+ routePoint.route_type === "wire" &&
238
+ nextRoutePoint?.route_type === "wire" &&
239
+ nextRoutePoint.layer === routePoint.layer &&
240
+ !LEGACY_VISUALIZATION_LAYERS.has(routePoint.layer)
241
+ )
242
+ }),
243
+ )
244
+ const connectionNames = new Set(srj.connections.map(({ name }) => name))
245
+ const traceOnlyConnections: SimpleRouteJson["connections"] =
246
+ hasArbitraryCopperLayer
247
+ ? [
248
+ ...new Set(
249
+ (srj.traces ?? [])
250
+ .map(({ connection_name }) => connection_name)
251
+ .filter(
252
+ (connectionName) =>
253
+ connectionName && !connectionNames.has(connectionName),
254
+ ),
255
+ ),
256
+ ].map((name) => ({ name, pointsToConnect: [] }))
257
+ : []
258
+ const visualizedConnections = [...srj.connections, ...traceOnlyConnections]
259
+ const colorMap = getColorMap(visualizedConnections)
260
+ const formatObstacleLabel = createObstacleLabelFormatter({
261
+ ...srj,
262
+ connections: visualizedConnections,
263
+ })
264
+ const lines: Line[] = []
265
+ const circles: Circle[] = []
266
+ const rects: Rect[] = []
267
+ const points: Point[] = []
268
+
269
+ for (const connection of visualizedConnections) {
270
+ for (const point of connection.pointsToConnect) {
271
+ const pointLayers = getPointLayers(point)
272
+ const rootNames = connection.__rootConnectionNames ?? [connection.name]
273
+ points.push({
274
+ x: point.x,
275
+ y: point.y,
276
+ color: colorMap[connection.name]!,
277
+ layer: getGraphicsLayer(layerNames, pointLayers),
278
+ label: [
279
+ connection.name,
280
+ rootNames.join(", "),
281
+ pointLayers.join(","),
282
+ ].join("\n"),
283
+ })
284
+ }
285
+ }
286
+
287
+ for (const trace of srj.traces ?? []) {
288
+ const jumpers = trace.route.filter(
289
+ (routePoint) => routePoint.route_type === "jumper",
290
+ )
291
+ const isWireSegmentInsideJumper = (
292
+ start: { x: number; y: number },
293
+ end: { x: number; y: number },
294
+ ): boolean =>
295
+ jumpers.some((jumper) => {
296
+ const tolerance = 0.01
297
+ return (
298
+ (Math.abs(start.x - jumper.start.x) < tolerance &&
299
+ Math.abs(start.y - jumper.start.y) < tolerance &&
300
+ Math.abs(end.x - jumper.end.x) < tolerance &&
301
+ Math.abs(end.y - jumper.end.y) < tolerance) ||
302
+ (Math.abs(start.x - jumper.end.x) < tolerance &&
303
+ Math.abs(start.y - jumper.end.y) < tolerance &&
304
+ Math.abs(end.x - jumper.start.x) < tolerance &&
305
+ Math.abs(end.y - jumper.start.y) < tolerance)
306
+ )
307
+ })
308
+
309
+ for (const routePoint of trace.route) {
310
+ if (routePoint.route_type === "via") {
311
+ const viaLayers = getViaLayerNames(
312
+ layerNames,
313
+ routePoint.from_layer,
314
+ routePoint.to_layer,
315
+ )
316
+ circles.push({
317
+ center: { x: routePoint.x, y: routePoint.y },
318
+ radius: (routePoint.via_diameter ?? getViaPadDiameter(srj)) / 2,
319
+ fill: hasArbitraryCopperLayer
320
+ ? colorMap[trace.connection_name]!
321
+ : "blue",
322
+ stroke: "none",
323
+ layer: getGraphicsLayer(layerNames, viaLayers),
324
+ })
325
+ } else if (routePoint.route_type === "through_obstacle") {
326
+ lines.push({
327
+ points: [routePoint.start, routePoint.end],
328
+ strokeColor: transparentize(
329
+ colorMap[trace.connection_name] ?? "purple",
330
+ 0.35,
331
+ ),
332
+ strokeWidth: routePoint.width,
333
+ strokeDash: [0.1, 0.1],
334
+ layer: getGraphicsLayer(layerNames, [
335
+ routePoint.from_layer,
336
+ routePoint.to_layer,
337
+ ]),
338
+ label: `${trace.connection_name} through_obstacle`,
339
+ })
340
+ }
341
+ }
342
+
343
+ for (
344
+ let routePointIndex = 0;
345
+ routePointIndex < trace.route.length - 1;
346
+ routePointIndex++
347
+ ) {
348
+ const routePoint = trace.route[routePointIndex]!
349
+ const nextRoutePoint = trace.route[routePointIndex + 1]!
350
+ if (routePoint.route_type === "jumper") {
351
+ const color =
352
+ colorMap[trace.connection_name] ?? "rgba(255, 165, 0, 0.8)"
353
+ const dimensions =
354
+ JUMPER_DIMENSIONS[
355
+ routePoint.footprint === "1206x4_pair" ? "1206x4_pair" : "0603"
356
+ ]
357
+ const horizontal =
358
+ Math.abs(routePoint.end.x - routePoint.start.x) >
359
+ Math.abs(routePoint.end.y - routePoint.start.y)
360
+ const padWidth = horizontal ? dimensions.padLength : dimensions.padWidth
361
+ const padHeight = horizontal
362
+ ? dimensions.padWidth
363
+ : dimensions.padLength
364
+ const layer = getGraphicsLayer(layerNames, [routePoint.layer])
365
+ for (const center of [routePoint.start, routePoint.end]) {
366
+ rects.push({
367
+ center,
368
+ width: padWidth,
369
+ height: padHeight,
370
+ fill: transparentize(color, 0.5),
371
+ stroke: "rgba(0, 0, 0, 0.5)",
372
+ layer,
373
+ })
374
+ }
375
+ lines.push({
376
+ points: [routePoint.start, routePoint.end],
377
+ strokeColor: "rgba(100, 100, 100, 0.8)",
378
+ strokeWidth: dimensions.padWidth * 0.3,
379
+ layer,
380
+ })
381
+ } else if (
382
+ routePoint.route_type === "wire" &&
383
+ nextRoutePoint.route_type === "wire" &&
384
+ nextRoutePoint.layer === routePoint.layer &&
385
+ !isWireSegmentInsideJumper(routePoint, nextRoutePoint)
386
+ ) {
387
+ const layerIndex = getLayerIndex(layerNames, routePoint.layer)
388
+ lines.push({
389
+ points: [
390
+ { x: routePoint.x, y: routePoint.y },
391
+ { x: nextRoutePoint.x, y: nextRoutePoint.y },
392
+ ],
393
+ layer: `z${layerIndex}`,
394
+ strokeWidth: routePoint.width,
395
+ strokeColor: getCopperLayerColor(layerIndex),
396
+ ...(hasArbitraryCopperLayer ? { label: trace.connection_name } : {}),
397
+ })
398
+ }
399
+ }
400
+ }
401
+
402
+ for (const obstacle of srj.obstacles) {
403
+ if (obstacle.isCopperPour) continue
404
+ const layerIndexes = getObstacleLayerIndexes(obstacle, layerNames)
405
+ if (layerIndexes.length === 0) {
406
+ throw new Error(
407
+ `FanoutSolver: cannot visualize obstacle "${obstacle.obstacleId ?? "unknown"}" without a valid layer`,
408
+ )
409
+ }
410
+ const onlyLayerName =
411
+ layerIndexes.length === 1 ? layerNames[layerIndexes[0]!] : undefined
412
+ const fill = transparentize(
413
+ onlyLayerName === "bottom" ? "blue" : "red",
414
+ 0.5 ** layerIndexes.length,
415
+ )
416
+ const shape = (obstacle as Obstacle & { shape?: string }).shape
417
+ const common = {
418
+ center: obstacle.center,
419
+ fill,
420
+ layer: `z${layerIndexes.join(",")}`,
421
+ label: formatObstacleLabel(obstacle),
422
+ }
423
+ if (shape === "circle") {
424
+ circles.push({
425
+ ...common,
426
+ radius: Math.min(obstacle.width, obstacle.height) / 2,
427
+ })
428
+ } else {
429
+ rects.push({
430
+ ...common,
431
+ width: obstacle.width,
432
+ height: obstacle.height,
433
+ ccwRotationDegrees: obstacle.ccwRotationDegrees,
434
+ })
435
+ }
436
+ }
437
+
438
+ for (const jumper of srj.jumpers ?? []) {
439
+ for (const pad of jumper.pads) {
440
+ rects.push({
441
+ center: pad.center,
442
+ width: pad.width,
443
+ height: pad.height,
444
+ ccwRotationDegrees: pad.ccwRotationDegrees,
445
+ fill: "rgba(255, 165, 0, 0.3)",
446
+ stroke: "rgba(255, 165, 0, 0.8)",
447
+ layer: getGraphicsLayer(layerNames, pad.layers),
448
+ })
449
+ }
450
+ }
451
+
452
+ return { rects, circles, lines, points }
453
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",