@tscircuit/fanout-solver 0.0.28 → 0.0.30

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,
@@ -262,7 +259,7 @@ function getBusDepthInRows(bus: PreparedBus): number {
262
259
  )
263
260
  }
264
261
 
265
- function createPreferredLayerAssignment(params: {
262
+ function createInitialLayerAssignment(params: {
266
263
  buses: PreparedBus[]
267
264
  escapeLayers: string[]
268
265
  escapeLayersByBusId: Readonly<Record<string, readonly string[]>>
@@ -317,16 +314,16 @@ function createPreferredLayerAssignment(params: {
317
314
  }
318
315
 
319
316
  function prioritizeLayerAssignment(params: {
320
- preferredAssignment: Readonly<Record<string, string>>
317
+ initialAssignment: Readonly<Record<string, string>>
321
318
  generatedAssignments: Array<Readonly<Record<string, string>>>
322
319
  maxAssignments: number
323
320
  }): Array<Readonly<Record<string, string>>> {
324
- const { preferredAssignment, generatedAssignments, maxAssignments } = params
325
- const preferredKey = JSON.stringify(preferredAssignment)
321
+ const { initialAssignment, generatedAssignments, maxAssignments } = params
322
+ const initialKey = JSON.stringify(initialAssignment)
326
323
  return [
327
- preferredAssignment,
324
+ initialAssignment,
328
325
  ...generatedAssignments.filter(
329
- (assignment) => JSON.stringify(assignment) !== preferredKey,
326
+ (assignment) => JSON.stringify(assignment) !== initialKey,
330
327
  ),
331
328
  ].slice(0, maxAssignments)
332
329
  }
@@ -338,7 +335,12 @@ function getCandidateEscapeLayersForBus(params: {
338
335
  staticClearanceCache: RouteBusStaticClearanceCache
339
336
  }): string[] {
340
337
  const { bus, srj, config, staticClearanceCache } = params
341
- const individuallyRoutableLayers = config.escapeLayers.filter(
338
+ const busAllowedLayers = bus.allowedLayers
339
+ const allowedEscapeLayers =
340
+ busAllowedLayers === undefined
341
+ ? config.escapeLayers
342
+ : config.escapeLayers.filter((layer) => busAllowedLayers.includes(layer))
343
+ const individuallyRoutableLayers = allowedEscapeLayers.filter(
342
344
  (targetLayer) =>
343
345
  routeBus({
344
346
  srj,
@@ -360,9 +362,11 @@ function getCandidateEscapeLayersForBus(params: {
360
362
  // route this bus by itself cannot become viable later in an assignment.
361
363
  // Preserve the original candidates when none route so impossible problems
362
364
  // still produce the usual failed-solver result instead of throwing here.
363
- return individuallyRoutableLayers.length > 0
364
- ? individuallyRoutableLayers
365
- : config.escapeLayers
365
+ const candidateLayers =
366
+ individuallyRoutableLayers.length > 0
367
+ ? individuallyRoutableLayers
368
+ : allowedEscapeLayers
369
+ return candidateLayers
366
370
  }
367
371
 
368
372
  export class FanoutSolver extends BaseSolver {
@@ -403,6 +407,24 @@ export class FanoutSolver extends BaseSolver {
403
407
  this.config = resolveConfig(inputSrj, options)
404
408
  this.preparedBuses = prepareFanoutBuses(inputSrj, options)
405
409
  for (const bus of this.preparedBuses) {
410
+ for (const allowedLayer of bus.allowedLayers ?? []) {
411
+ if (!this.config.layerNames.includes(allowedLayer)) {
412
+ throw new Error(
413
+ `FanoutSolver: bus "${bus.busId}" allows unavailable layer "${allowedLayer}"`,
414
+ )
415
+ }
416
+ }
417
+ if (
418
+ bus.termination.type === "boundary" &&
419
+ bus.allowedLayers !== undefined &&
420
+ !bus.allowedLayers.some((layer) =>
421
+ this.config.escapeLayers.includes(layer),
422
+ )
423
+ ) {
424
+ throw new Error(
425
+ `FanoutSolver: bus "${bus.busId}" has no allowed layer in escapeLayers`,
426
+ )
427
+ }
406
428
  if (bus.termination.type !== "plane") continue
407
429
  const planeLayer = bus.termination.layer
408
430
  if (!this.config.layerNames.includes(planeLayer)) {
@@ -410,6 +432,14 @@ export class FanoutSolver extends BaseSolver {
410
432
  `FanoutSolver: plane-terminated bus "${bus.busId}" targets unavailable layer "${planeLayer}"`,
411
433
  )
412
434
  }
435
+ if (
436
+ bus.allowedLayers !== undefined &&
437
+ !bus.allowedLayers.includes(planeLayer)
438
+ ) {
439
+ throw new Error(
440
+ `FanoutSolver: plane-terminated bus "${bus.busId}" targets disallowed layer "${planeLayer}"`,
441
+ )
442
+ }
413
443
  if (
414
444
  bus.connections.some(
415
445
  (connection) => connection.sourceLayer === planeLayer,
@@ -457,7 +487,7 @@ export class FanoutSolver extends BaseSolver {
457
487
  ...fixedPlaneAssignments,
458
488
  }))
459
489
  this.layerAssignments = prioritizeLayerAssignment({
460
- preferredAssignment: createPreferredLayerAssignment({
490
+ initialAssignment: createInitialLayerAssignment({
461
491
  buses: this.preparedBuses,
462
492
  escapeLayers: this.config.escapeLayers,
463
493
  escapeLayersByBusId,
@@ -489,6 +519,7 @@ export class FanoutSolver extends BaseSolver {
489
519
  viaHoleDiameter: this.config.viaHoleDiameter,
490
520
  clearance: this.config.clearance,
491
521
  effort: this.options.endpointCompletionEffort,
522
+ routeDownstreamConnections: this.options.routeDownstreamConnections,
492
523
  })
493
524
  }
494
525
 
@@ -1254,46 +1285,6 @@ export class FanoutSolver extends BaseSolver {
1254
1285
  this.endpointCompletion?.simpleRouteJson ??
1255
1286
  this.bestAttempt?.outputSrj ??
1256
1287
  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
- }
1288
+ return visualizeSimpleRouteJson(visualizedSrj)
1298
1289
  }
1299
1290
  }
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,
@@ -407,6 +407,26 @@ function resolvePreferredExit(
407
407
  return value
408
408
  }
409
409
 
410
+ function resolveAllowedLayers(
411
+ busId: string,
412
+ allowedLayers: readonly string[] | undefined,
413
+ ): string[] | undefined {
414
+ if (allowedLayers === undefined) return undefined
415
+ if (allowedLayers.length === 0) {
416
+ throw new Error(
417
+ `FanoutSolver: bus "${busId}" must allow at least one layer`,
418
+ )
419
+ }
420
+ for (const layer of allowedLayers) {
421
+ if (typeof layer !== "string" || layer.length === 0) {
422
+ throw new Error(
423
+ `FanoutSolver: bus "${busId}" has an invalid allowed layer`,
424
+ )
425
+ }
426
+ }
427
+ return [...new Set(allowedLayers)]
428
+ }
429
+
410
430
  export function resolveAvailableBoundaryRegions(
411
431
  value: readonly FanoutAvailableCornerAndSideInput[] | undefined,
412
432
  ): AvailableBoundaryRegion[] | undefined {
@@ -491,6 +511,10 @@ function resolveBusSpecs(
491
511
  (requestedBus as FanoutBusSpec).preferredExit ??
492
512
  options.defaultPreferredExit,
493
513
  )
514
+ const allowedLayers = resolveAllowedLayers(
515
+ requestedBus.busId,
516
+ (requestedBus as FanoutBusSpec).allowedLayers,
517
+ )
494
518
  if (termination.type === "plane" && preferredExit !== undefined) {
495
519
  throw new Error(
496
520
  `FanoutSolver: plane-terminated bus "${requestedBus.busId}" cannot also specify preferredExit`,
@@ -506,6 +530,7 @@ function resolveBusSpecs(
506
530
  (requestedBus as FanoutBusSpec).direction ??
507
531
  options.defaultDirection,
508
532
  preferredExit,
533
+ ...(allowedLayers === undefined ? {} : { allowedLayers }),
509
534
  termination,
510
535
  })
511
536
  }
@@ -1020,6 +1045,7 @@ export function prepareFanoutBuses(
1020
1045
  busId: busSpec.busId,
1021
1046
  direction: resolvedExit.direction,
1022
1047
  preferredExit: resolvedExit.preferredExit,
1048
+ allowedLayers: busSpec.allowedLayers,
1023
1049
  termination: busSpec.termination ?? { type: "boundary" },
1024
1050
  connections: preparedConnections,
1025
1051
  componentId: sourceGrid.componentId,
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"
@@ -63,6 +76,8 @@ export interface FanoutBusSpec extends SimpleRouteBus {
63
76
  sourceComponentId?: string
64
77
  direction?: FanoutDirection
65
78
  preferredExit?: FanoutBorderTarget
79
+ /** Layers to which this bus is allowed to escape. */
80
+ allowedLayers?: readonly string[]
66
81
  /**
67
82
  * Preferred downstream routing point for each connection after it leaves the
68
83
  * fanout boundary. This is routing guidance only and does not replace the
@@ -127,8 +142,13 @@ export interface FanoutSolverOptions {
127
142
  * with the independent endpoint-connectivity and emitted-copper validators.
128
143
  */
129
144
  completeOriginalEndpoints?: boolean
130
- /** Effort passed to the bounded downstream capacity-router pass. */
145
+ /** Effort passed to the optional bounded downstream-router pass. */
131
146
  endpointCompletionEffort?: number
147
+ /**
148
+ * Host-provided fallback for unresolved endpoint connections. The fanout
149
+ * package deliberately does not import a board-level autorouter at runtime.
150
+ */
151
+ routeDownstreamConnections?: FanoutDownstreamRouter
132
152
  }
133
153
 
134
154
  export interface FanoutAttemptSummary {
@@ -226,6 +246,8 @@ export interface PreparedBus {
226
246
  busId: string
227
247
  direction: FanoutDirection
228
248
  preferredExit?: FanoutBorderTarget
249
+ /** Layers to which this bus is allowed to escape. */
250
+ allowedLayers?: readonly string[]
229
251
  termination: FanoutBusTermination
230
252
  connections: PreparedConnection[]
231
253
  componentId: string
@@ -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.28",
3
+ "version": "0.0.30",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",