@tscircuit/fanout-solver 0.0.34 → 0.0.36

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
@@ -29,6 +29,10 @@ and treats each bus-layer decision atomically.
29
29
  (`left`, `right`, `top`, or `bottom`) or corner (`top-left`, `top-right`,
30
30
  `bottom-left`, or `bottom-right`). A corner chooses a compatible adjacent
31
31
  edge and reserves the bus at that end of the border.
32
+ - Accepts an unambiguous `exitPosition` bus field when the local pad escape and
33
+ final boundary edge differ. For example, `rightside_top` escapes locally
34
+ upward into the upper band and terminates on the right boundary, while
35
+ `topside_right` escapes locally rightward and terminates on the top boundary.
32
36
  - `availableCornersAndSides` can restrict every boundary-terminated bus to
33
37
  named regions of the shared boundary. For example,
34
38
  `['top_left', 'top_middle', 'top_right']` allows only top-edge exits;
@@ -161,6 +165,16 @@ const autorouter = new CapacityMeshSolver(
161
165
  autorouter.solve()
162
166
  ```
163
167
 
168
+ Canonical exit positions are edge-first: `topside_left`, `topside_center`,
169
+ `topside_right`, `rightside_top`, `rightside_center`, `rightside_bottom`,
170
+ `bottomside_right`, `bottomside_center`, `bottomside_left`, `leftside_bottom`,
171
+ `leftside_center`, `leftside_top`, and `center`. They normalize atomically into
172
+ the local `direction`, boundary-band `preferredExit`, and physical `exitEdge`;
173
+ conflicting bus-level legacy fields are rejected. Existing buses that omit
174
+ `exitPosition` retain their previous behavior. Hosts can import
175
+ `getFanoutExitPositionConfig` to inspect the same normalized tuple without
176
+ duplicating this mapping.
177
+
164
178
  The downstream callback is optional. It lets the application choose its
165
179
  board-level router while keeping `@tscircuit/fanout-solver` free of a runtime
166
180
  autorouter import. Returned traces are still accepted only after the fanout
@@ -0,0 +1,52 @@
1
+ import type { FanoutBorderTarget, FanoutDirection, FanoutEdge } from "./types"
2
+
3
+ export function getExitEdgeForDirection(
4
+ direction: FanoutDirection,
5
+ ): FanoutEdge {
6
+ switch (direction) {
7
+ case "left":
8
+ return "left"
9
+ case "right":
10
+ return "right"
11
+ case "up":
12
+ return "top"
13
+ case "down":
14
+ return "bottom"
15
+ }
16
+ }
17
+
18
+ export function getDirectionForExitEdge(exitEdge: FanoutEdge): FanoutDirection {
19
+ switch (exitEdge) {
20
+ case "left":
21
+ return "left"
22
+ case "right":
23
+ return "right"
24
+ case "top":
25
+ return "up"
26
+ case "bottom":
27
+ return "down"
28
+ }
29
+ }
30
+
31
+ export function borderTargetIncludesEdge(
32
+ preferredExit: FanoutBorderTarget,
33
+ exitEdge: FanoutEdge,
34
+ ): boolean {
35
+ return preferredExit === exitEdge || preferredExit.includes(exitEdge)
36
+ }
37
+
38
+ /**
39
+ * Returns the lower/left or upper/right band selected along an explicit edge.
40
+ * Edge-center targets do not select a corner band.
41
+ */
42
+ export function getCornerBandSide(
43
+ exitEdge: FanoutEdge | undefined,
44
+ preferredExit: FanoutBorderTarget | undefined,
45
+ ): "minimum" | "maximum" | undefined {
46
+ if (!exitEdge || !preferredExit?.includes("-")) return undefined
47
+ if (!borderTargetIncludesEdge(preferredExit, exitEdge)) return undefined
48
+ if (exitEdge === "left" || exitEdge === "right") {
49
+ return preferredExit.startsWith("top-") ? "maximum" : "minimum"
50
+ }
51
+ return preferredExit.endsWith("-right") ? "maximum" : "minimum"
52
+ }
@@ -0,0 +1,78 @@
1
+ import type { FanoutExitPosition, FanoutExitPositionConfig } from "./types"
2
+
3
+ const FANOUT_EXIT_POSITION_CONFIGS: Readonly<
4
+ Record<FanoutExitPosition, Readonly<FanoutExitPositionConfig>>
5
+ > = {
6
+ topside_left: {
7
+ direction: "left",
8
+ preferredExit: "top-left",
9
+ exitEdge: "top",
10
+ },
11
+ topside_center: {
12
+ direction: "up",
13
+ preferredExit: "top",
14
+ exitEdge: "top",
15
+ },
16
+ topside_right: {
17
+ direction: "right",
18
+ preferredExit: "top-right",
19
+ exitEdge: "top",
20
+ },
21
+ rightside_top: {
22
+ direction: "up",
23
+ preferredExit: "top-right",
24
+ exitEdge: "right",
25
+ },
26
+ rightside_center: {
27
+ direction: "right",
28
+ preferredExit: "right",
29
+ exitEdge: "right",
30
+ },
31
+ rightside_bottom: {
32
+ direction: "down",
33
+ preferredExit: "bottom-right",
34
+ exitEdge: "right",
35
+ },
36
+ bottomside_right: {
37
+ direction: "right",
38
+ preferredExit: "bottom-right",
39
+ exitEdge: "bottom",
40
+ },
41
+ bottomside_center: {
42
+ direction: "down",
43
+ preferredExit: "bottom",
44
+ exitEdge: "bottom",
45
+ },
46
+ bottomside_left: {
47
+ direction: "left",
48
+ preferredExit: "bottom-left",
49
+ exitEdge: "bottom",
50
+ },
51
+ leftside_bottom: {
52
+ direction: "down",
53
+ preferredExit: "bottom-left",
54
+ exitEdge: "left",
55
+ },
56
+ leftside_center: {
57
+ direction: "left",
58
+ preferredExit: "left",
59
+ exitEdge: "left",
60
+ },
61
+ leftside_top: {
62
+ direction: "up",
63
+ preferredExit: "top-left",
64
+ exitEdge: "left",
65
+ },
66
+ center: {},
67
+ }
68
+
69
+ /** Resolves a canonical exit position into the solver's orthogonal fields. */
70
+ export function getFanoutExitPositionConfig(
71
+ exitPosition: FanoutExitPosition,
72
+ ): Readonly<FanoutExitPositionConfig> {
73
+ const config = FANOUT_EXIT_POSITION_CONFIGS[exitPosition]
74
+ if (!config) {
75
+ throw new Error(`Invalid fanout exit position "${exitPosition}"`)
76
+ }
77
+ return config
78
+ }
@@ -1,10 +1,11 @@
1
1
  import type { SimpleRouteJson } from "@tscircuit/capacity-autorouter"
2
2
  import { BaseSolver } from "@tscircuit/solver-utils"
3
3
  import type { GraphicsObject } from "graphics-debug"
4
+ import { getCornerBandSide } from "./boundary-exit"
4
5
  import { buildOutputSimpleRouteJson } from "./build-output"
5
6
  import {
6
- completeOriginalEndpoints,
7
7
  type CompleteOriginalEndpointsResult,
8
+ completeOriginalEndpoints,
8
9
  } from "./complete-original-endpoints"
9
10
  import { generateLayerAssignments, getCopperLayerNames } from "./layer-names"
10
11
  import {
@@ -12,14 +13,12 @@ import {
12
13
  resolveAvailableBoundaryRegions,
13
14
  } from "./prepare-buses"
14
15
  import {
16
+ type RouteBusStaticClearanceCache,
15
17
  routeBus,
16
18
  routeBusAlternatives,
17
- type RouteBusStaticClearanceCache,
18
19
  } from "./route-bus"
19
20
  import { routeSingleLayerWithAdaptiveExits } from "./route-single-layer-adaptive-exits"
20
21
  import { routeSingleLayerWithPushAndShove } from "./route-single-layer-push-shove"
21
- import { validateFanoutSolution } from "./validate-fanout-solution"
22
- import { visualizeSimpleRouteJson } from "./visualize-simple-route-json"
23
22
  import type {
24
23
  AssignmentAttempt,
25
24
  Bounds,
@@ -30,6 +29,8 @@ import type {
30
29
  FanoutSolverOutput,
31
30
  PreparedBus,
32
31
  } from "./types"
32
+ import { validateFanoutSolution } from "./validate-fanout-solution"
33
+ import { visualizeSimpleRouteJson } from "./visualize-simple-route-json"
33
34
 
34
35
  interface ResolvedFanoutConfig {
35
36
  traceWidth: number
@@ -146,6 +147,45 @@ function resolveConfig(
146
147
  }
147
148
  }
148
149
 
150
+ function validateCornerBandCapacities(
151
+ buses: readonly PreparedBus[],
152
+ config: ResolvedFanoutConfig,
153
+ ): void {
154
+ const checkedBands = new Set<string>()
155
+ const exitPitch = Math.max(
156
+ config.traceWidth + config.clearance,
157
+ config.viaDiameter + config.clearance,
158
+ )
159
+ // Keep the block clear of the physical end of the edge and leave one
160
+ // unoccupied via-pitch between the minimum and maximum quarter bands.
161
+ const endInset = Math.max(
162
+ config.viaDiameter / 2 + config.clearance,
163
+ exitPitch,
164
+ )
165
+
166
+ for (const bus of buses) {
167
+ const side = getCornerBandSide(bus.exitEdge, bus.preferredExit)
168
+ if (!bus.exitEdge || !side) continue
169
+ const bandKey = `${bus.exitEdge}:${side}`
170
+ if (checkedBands.has(bandKey)) continue
171
+ checkedBands.add(bandKey)
172
+
173
+ const edgeLength =
174
+ bus.exitEdge === "left" || bus.exitEdge === "right"
175
+ ? bus.sharedBoundary.maxY - bus.sharedBoundary.minY
176
+ : bus.sharedBoundary.maxX - bus.sharedBoundary.minX
177
+ const connectionCount =
178
+ bus.cornerBandConnectionCount ?? bus.connections.length
179
+ const halfTrackSpan = ((connectionCount - 1) * exitPitch) / 2
180
+ const availableHalfTrackSpan = edgeLength / 4 - endInset
181
+ if (halfTrackSpan > availableHalfTrackSpan + 1e-6) {
182
+ throw new Error(
183
+ `FanoutSolver: ${side} band on the ${bus.exitEdge} edge cannot fit ${connectionCount} via-safe exits`,
184
+ )
185
+ }
186
+ }
187
+ }
188
+
149
189
  function assignmentLoadPenalty(
150
190
  assignment: Readonly<Record<string, string>>,
151
191
  buses: readonly PreparedBus[],
@@ -411,6 +451,7 @@ export class FanoutSolver extends BaseSolver {
411
451
  }
412
452
  this.config = resolveConfig(inputSrj, options)
413
453
  this.preparedBuses = prepareFanoutBuses(this.routingSrj, options)
454
+ validateCornerBandCapacities(this.preparedBuses, this.config)
414
455
  for (const bus of this.preparedBuses) {
415
456
  for (const allowedLayer of bus.allowedLayers ?? []) {
416
457
  if (!this.config.layerNames.includes(allowedLayer)) {
@@ -566,7 +607,13 @@ export class FanoutSolver extends BaseSolver {
566
607
  let failedBusIds: string[] = []
567
608
  let blockingBusCounts = new Map<string, number>()
568
609
  const isSingleLayerFanout = this.config.escapeLayers.length === 1
569
- if (isSingleLayerFanout && this.config.singleLayerPushAndShove) {
610
+ const useSingleLayerPushAndShove =
611
+ isSingleLayerFanout &&
612
+ this.config.singleLayerPushAndShove &&
613
+ !this.preparedBuses.some(
614
+ (bus) => bus.exitEdge && bus.preferredExit?.includes("-"),
615
+ )
616
+ if (useSingleLayerPushAndShove) {
570
617
  const singleLayerParams = {
571
618
  srj: this.routingSrj,
572
619
  buses: this.preparedBuses,
@@ -609,9 +656,7 @@ export class FanoutSolver extends BaseSolver {
609
656
  )
610
657
 
611
658
  let routingPrefixKey = `${routingStrategy}|`
612
- for (const bus of isSingleLayerFanout && this.config.singleLayerPushAndShove
613
- ? []
614
- : busesInRoutingOrder) {
659
+ for (const bus of useSingleLayerPushAndShove ? [] : busesInRoutingOrder) {
615
660
  const targetLayer = busLayerAssignments[bus.busId]
616
661
  if (!targetLayer) {
617
662
  throw new Error(
package/lib/index.ts CHANGED
@@ -1,19 +1,8 @@
1
- export { FanoutSolver } from "./fanout-solver"
2
1
  export { completeOriginalEndpoints } from "./complete-original-endpoints"
2
+ export { getFanoutExitPositionConfig } from "./fanout-exit-position"
3
+ export { FanoutSolver } from "./fanout-solver"
3
4
  export { getCopperLayerColor } from "./layer-colors"
4
5
  export { getCopperLayerNames } from "./layer-names"
5
- export { validateOriginalEndpointConnectivity } from "./validate-original-endpoint-connectivity"
6
- export { validateRoutedCopperDrc } from "./validate-routed-copper-drc"
7
- export { validateFanoutSolution } from "./validate-fanout-solution"
8
- export type {
9
- OriginalEndpointConnectivityIssue,
10
- OriginalEndpointConnectivityReport,
11
- } from "./validate-original-endpoint-connectivity"
12
- export type {
13
- RoutedCopperDrcIssue,
14
- RoutedCopperDrcIssueCode,
15
- RoutedCopperDrcReport,
16
- } from "./validate-routed-copper-drc"
17
6
  export type {
18
7
  Bounds,
19
8
  FanoutAttemptSummary,
@@ -30,6 +19,8 @@ export type {
30
19
  FanoutDownstreamRouterOptions,
31
20
  FanoutEdge,
32
21
  FanoutEndpointCompletionReport,
22
+ FanoutExitPosition,
23
+ FanoutExitPositionConfig,
33
24
  FanoutPlaneConnectivity,
34
25
  FanoutPlaneTermination,
35
26
  FanoutRoutePlan,
@@ -41,3 +32,15 @@ export type {
41
32
  PreparedBus,
42
33
  SimpleRouteJsonWithFanoutPlanes,
43
34
  } from "./types"
35
+ export { validateFanoutSolution } from "./validate-fanout-solution"
36
+ export type {
37
+ OriginalEndpointConnectivityIssue,
38
+ OriginalEndpointConnectivityReport,
39
+ } from "./validate-original-endpoint-connectivity"
40
+ export { validateOriginalEndpointConnectivity } from "./validate-original-endpoint-connectivity"
41
+ export type {
42
+ RoutedCopperDrcIssue,
43
+ RoutedCopperDrcIssueCode,
44
+ RoutedCopperDrcReport,
45
+ } from "./validate-routed-copper-drc"
46
+ export { validateRoutedCopperDrc } from "./validate-routed-copper-drc"
@@ -4,6 +4,8 @@ import type {
4
4
  SimpleRouteConnection,
5
5
  SimpleRouteJson,
6
6
  } from "@tscircuit/capacity-autorouter"
7
+ import { borderTargetIncludesEdge, getCornerBandSide } from "./boundary-exit"
8
+ import { getFanoutExitPositionConfig } from "./fanout-exit-position"
7
9
  import { distance, pointIsInsideObstacle } from "./geometry"
8
10
  import type {
9
11
  Bounds,
@@ -12,6 +14,9 @@ import type {
12
14
  FanoutBusSpec,
13
15
  FanoutBusTermination,
14
16
  FanoutDirection,
17
+ FanoutEdge,
18
+ FanoutExitPosition,
19
+ FanoutExitPositionConfig,
15
20
  FanoutSolverOptions,
16
21
  PreparedBus,
17
22
  PreparedConnection,
@@ -20,6 +25,8 @@ import type {
20
25
  export interface AvailableBoundaryRegion {
21
26
  direction: FanoutDirection
22
27
  preferredExit: FanoutBorderTarget
28
+ /** Physical shared-boundary edge selected by the first region token. */
29
+ exitEdge: FanoutEdge
23
30
  }
24
31
 
25
32
  const FANOUT_BORDER_TARGETS = new Set<FanoutBorderTarget>([
@@ -33,72 +40,90 @@ const FANOUT_BORDER_TARGETS = new Set<FanoutBorderTarget>([
33
40
  "bottom-right",
34
41
  ])
35
42
 
43
+ const FANOUT_EDGES = new Set<FanoutEdge>(["left", "right", "top", "bottom"])
44
+
36
45
  const AVAILABLE_BOUNDARY_REGIONS: Readonly<
37
46
  Record<FanoutAvailableCornerAndSideInput, AvailableBoundaryRegion>
38
47
  > = {
39
48
  top_left: {
40
49
  direction: "up",
41
50
  preferredExit: "top-left",
51
+ exitEdge: "top",
42
52
  },
43
53
  top_middle: {
44
54
  direction: "up",
45
55
  preferredExit: "top",
56
+ exitEdge: "top",
46
57
  },
47
58
  top_right: {
48
59
  direction: "up",
49
60
  preferredExit: "top-right",
61
+ exitEdge: "top",
50
62
  },
51
63
  right_top: {
52
64
  direction: "right",
53
65
  preferredExit: "top-right",
66
+ exitEdge: "right",
54
67
  },
55
68
  right_middle: {
56
69
  direction: "right",
57
70
  preferredExit: "right",
71
+ exitEdge: "right",
58
72
  },
59
73
  right_bottom: {
60
74
  direction: "right",
61
75
  preferredExit: "bottom-right",
76
+ exitEdge: "right",
62
77
  },
63
78
  bottom_right: {
64
79
  direction: "down",
65
80
  preferredExit: "bottom-right",
81
+ exitEdge: "bottom",
66
82
  },
67
83
  bottom_middle: {
68
84
  direction: "down",
69
85
  preferredExit: "bottom",
86
+ exitEdge: "bottom",
70
87
  },
71
88
  bottom_left: {
72
89
  direction: "down",
73
90
  preferredExit: "bottom-left",
91
+ exitEdge: "bottom",
74
92
  },
75
93
  left_bottom: {
76
94
  direction: "left",
77
95
  preferredExit: "bottom-left",
96
+ exitEdge: "left",
78
97
  },
79
98
  left_middle: {
80
99
  direction: "left",
81
100
  preferredExit: "left",
101
+ exitEdge: "left",
82
102
  },
83
103
  left_top: {
84
104
  direction: "left",
85
105
  preferredExit: "top-left",
106
+ exitEdge: "left",
86
107
  },
87
108
  top: {
88
109
  direction: "up",
89
110
  preferredExit: "top",
111
+ exitEdge: "top",
90
112
  },
91
113
  right: {
92
114
  direction: "right",
93
115
  preferredExit: "right",
116
+ exitEdge: "right",
94
117
  },
95
118
  bottom: {
96
119
  direction: "down",
97
120
  preferredExit: "bottom",
121
+ exitEdge: "bottom",
98
122
  },
99
123
  left: {
100
124
  direction: "left",
101
125
  preferredExit: "left",
126
+ exitEdge: "left",
102
127
  },
103
128
  }
104
129
 
@@ -407,6 +432,134 @@ function resolvePreferredExit(
407
432
  return value
408
433
  }
409
434
 
435
+ function resolveExitEdge(
436
+ busId: string,
437
+ value: FanoutEdge | undefined,
438
+ ): FanoutEdge | undefined {
439
+ if (value === undefined) return undefined
440
+ if (!FANOUT_EDGES.has(value)) {
441
+ throw new Error(
442
+ `FanoutSolver: bus "${busId}" has invalid exitEdge "${value}"`,
443
+ )
444
+ }
445
+ return value
446
+ }
447
+
448
+ function resolveExitPosition(
449
+ busId: string,
450
+ value: FanoutExitPosition | undefined,
451
+ ): Readonly<FanoutExitPositionConfig> | undefined {
452
+ if (value === undefined) return undefined
453
+ try {
454
+ return getFanoutExitPositionConfig(value)
455
+ } catch {
456
+ throw new Error(
457
+ `FanoutSolver: bus "${busId}" has invalid exitPosition "${value}"`,
458
+ )
459
+ }
460
+ }
461
+
462
+ function assertExitPositionFieldMatches<T extends string>(params: {
463
+ busId: string
464
+ exitPosition: FanoutExitPosition
465
+ fieldName: "direction" | "preferredExit" | "exitEdge"
466
+ expected: T | undefined
467
+ actual: T | undefined
468
+ sourceName: string
469
+ }): void {
470
+ const { busId, exitPosition, fieldName, expected, actual, sourceName } =
471
+ params
472
+ if (actual === undefined || actual === expected) return
473
+ throw new Error(
474
+ `FanoutSolver: bus "${busId}" exitPosition "${exitPosition}" conflicts with ${sourceName} ${fieldName} "${actual}"`,
475
+ )
476
+ }
477
+
478
+ function resolveBusExitFields(params: {
479
+ busId: string
480
+ requestedBus: FanoutBusSpec
481
+ options: FanoutSolverOptions
482
+ }): Pick<
483
+ FanoutBusSpec,
484
+ "exitPosition" | "direction" | "preferredExit" | "exitEdge"
485
+ > {
486
+ const { busId, requestedBus, options } = params
487
+ const exitPosition = requestedBus.exitPosition
488
+ const exitPositionConfig = resolveExitPosition(busId, exitPosition)
489
+ const busPreferredExit = resolvePreferredExit(
490
+ busId,
491
+ requestedBus.preferredExit,
492
+ )
493
+ const optionPreferredExit = resolvePreferredExit(
494
+ busId,
495
+ options.busExitPreferences?.[busId],
496
+ )
497
+ const busExitEdge = resolveExitEdge(busId, requestedBus.exitEdge)
498
+
499
+ if (exitPositionConfig && exitPosition) {
500
+ for (const [actual, sourceName] of [
501
+ [requestedBus.direction, "bus"],
502
+ [options.busDirections?.[busId], "busDirections"],
503
+ ] as const) {
504
+ assertExitPositionFieldMatches({
505
+ busId,
506
+ exitPosition,
507
+ fieldName: "direction",
508
+ expected: exitPositionConfig.direction,
509
+ actual,
510
+ sourceName,
511
+ })
512
+ }
513
+ for (const [actual, sourceName] of [
514
+ [busPreferredExit, "bus"],
515
+ [optionPreferredExit, "busExitPreferences"],
516
+ ] as const) {
517
+ assertExitPositionFieldMatches({
518
+ busId,
519
+ exitPosition,
520
+ fieldName: "preferredExit",
521
+ expected: exitPositionConfig.preferredExit,
522
+ actual,
523
+ sourceName,
524
+ })
525
+ }
526
+ assertExitPositionFieldMatches({
527
+ busId,
528
+ exitPosition,
529
+ fieldName: "exitEdge",
530
+ expected: exitPositionConfig.exitEdge,
531
+ actual: busExitEdge,
532
+ sourceName: "bus",
533
+ })
534
+ return {
535
+ exitPosition,
536
+ ...(exitPositionConfig.direction
537
+ ? { direction: exitPositionConfig.direction }
538
+ : {}),
539
+ ...(exitPositionConfig.preferredExit
540
+ ? { preferredExit: exitPositionConfig.preferredExit }
541
+ : {}),
542
+ ...(exitPositionConfig.exitEdge
543
+ ? { exitEdge: exitPositionConfig.exitEdge }
544
+ : {}),
545
+ }
546
+ }
547
+
548
+ const direction =
549
+ options.busDirections?.[busId] ??
550
+ requestedBus.direction ??
551
+ options.defaultDirection
552
+ const preferredExit = resolvePreferredExit(
553
+ busId,
554
+ optionPreferredExit ?? busPreferredExit ?? options.defaultPreferredExit,
555
+ )
556
+ return {
557
+ ...(direction ? { direction } : {}),
558
+ ...(preferredExit ? { preferredExit } : {}),
559
+ ...(busExitEdge ? { exitEdge: busExitEdge } : {}),
560
+ }
561
+ }
562
+
410
563
  function resolveAllowedLayers(
411
564
  busId: string,
412
565
  allowedLayers: readonly string[] | undefined,
@@ -446,7 +599,7 @@ export function resolveAvailableBoundaryRegions(
446
599
  `FanoutSolver: invalid availableCornersAndSides value "${input}"`,
447
600
  )
448
601
  }
449
- const key = `${region.direction}:${region.preferredExit}`
602
+ const key = `${region.exitEdge}:${region.direction}:${region.preferredExit}`
450
603
  if (seen.has(key)) continue
451
604
  seen.add(key)
452
605
  regions.push(region)
@@ -505,17 +658,19 @@ function resolveBusSpecs(
505
658
  requestedBus.busId,
506
659
  (requestedBus as FanoutBusSpec).termination,
507
660
  )
508
- const preferredExit = resolvePreferredExit(
509
- requestedBus.busId,
510
- options.busExitPreferences?.[requestedBus.busId] ??
511
- (requestedBus as FanoutBusSpec).preferredExit ??
512
- options.defaultPreferredExit,
513
- )
661
+ const resolvedExitFields = resolveBusExitFields({
662
+ busId: requestedBus.busId,
663
+ requestedBus: requestedBus as FanoutBusSpec,
664
+ options,
665
+ })
514
666
  const allowedLayers = resolveAllowedLayers(
515
667
  requestedBus.busId,
516
668
  (requestedBus as FanoutBusSpec).allowedLayers,
517
669
  )
518
- if (termination.type === "plane" && preferredExit !== undefined) {
670
+ if (
671
+ termination.type === "plane" &&
672
+ resolvedExitFields.preferredExit !== undefined
673
+ ) {
519
674
  throw new Error(
520
675
  `FanoutSolver: plane-terminated bus "${requestedBus.busId}" cannot also specify preferredExit`,
521
676
  )
@@ -525,11 +680,7 @@ function resolveBusSpecs(
525
680
  sourceComponentId:
526
681
  (requestedBus as FanoutBusSpec).sourceComponentId ??
527
682
  options.sourceComponentId,
528
- direction:
529
- options.busDirections?.[requestedBus.busId] ??
530
- (requestedBus as FanoutBusSpec).direction ??
531
- options.defaultDirection,
532
- preferredExit,
683
+ ...resolvedExitFields,
533
684
  ...(allowedLayers === undefined ? {} : { allowedLayers }),
534
685
  termination,
535
686
  })
@@ -540,26 +691,26 @@ function resolveBusSpecs(
540
691
  const inferredBusId = inferBusId(connection)
541
692
  if (inferredBusId) {
542
693
  const existing = specsById.get(inferredBusId)
543
- specsById.set(inferredBusId, {
544
- busId: inferredBusId,
545
- connectionNames: [
546
- ...(existing?.connectionNames ?? []),
547
- connection.name,
548
- ],
549
- direction:
550
- options.busDirections?.[inferredBusId] ??
551
- existing?.direction ??
552
- options.defaultDirection,
553
- sourceComponentId:
554
- existing?.sourceComponentId ?? options.sourceComponentId,
555
- preferredExit: resolvePreferredExit(
556
- inferredBusId,
557
- options.busExitPreferences?.[inferredBusId] ??
558
- existing?.preferredExit ??
559
- options.defaultPreferredExit,
560
- ),
561
- termination: existing?.termination ?? { type: "boundary" },
562
- })
694
+ if (existing) {
695
+ specsById.set(inferredBusId, {
696
+ ...existing,
697
+ connectionNames: [...existing.connectionNames, connection.name],
698
+ })
699
+ } else {
700
+ specsById.set(inferredBusId, {
701
+ busId: inferredBusId,
702
+ connectionNames: [connection.name],
703
+ direction:
704
+ options.busDirections?.[inferredBusId] ?? options.defaultDirection,
705
+ sourceComponentId: options.sourceComponentId,
706
+ preferredExit: resolvePreferredExit(
707
+ inferredBusId,
708
+ options.busExitPreferences?.[inferredBusId] ??
709
+ options.defaultPreferredExit,
710
+ ),
711
+ termination: { type: "boundary" },
712
+ })
713
+ }
563
714
  } else {
564
715
  const singletonBusId = `connection:${connection.name}`
565
716
  specsById.set(singletonBusId, {
@@ -892,6 +1043,27 @@ function resolveAvailableBusExit(params: {
892
1043
  )[0]!
893
1044
  }
894
1045
 
1046
+ function validateExplicitExitAvailability(params: {
1047
+ busId: string
1048
+ exitEdge: FanoutEdge
1049
+ preferredExit: FanoutBorderTarget
1050
+ availableRegions: readonly AvailableBoundaryRegion[]
1051
+ }): void {
1052
+ const { busId, exitEdge, preferredExit, availableRegions } = params
1053
+ const requestedBandSide = getCornerBandSide(exitEdge, preferredExit)
1054
+ const hasCompatibleRegion = availableRegions.some(
1055
+ (region) =>
1056
+ region.exitEdge === exitEdge &&
1057
+ getCornerBandSide(region.exitEdge, region.preferredExit) ===
1058
+ requestedBandSide,
1059
+ )
1060
+ if (!hasCompatibleRegion) {
1061
+ throw new Error(
1062
+ `FanoutSolver: bus "${busId}" cannot use its requested exit with availableCornersAndSides`,
1063
+ )
1064
+ }
1065
+ }
1066
+
895
1067
  function resolveBusDirection(params: {
896
1068
  busId: string
897
1069
  explicitDirection?: FanoutDirection
@@ -1031,6 +1203,20 @@ export function prepareFanoutBuses(
1031
1203
  sourceGrid,
1032
1204
  preparedConnections,
1033
1205
  } of resolvedBusInputs) {
1206
+ if (busSpec.exitEdge && !busSpec.preferredExit) {
1207
+ throw new Error(
1208
+ `FanoutSolver: bus "${busSpec.busId}" exitEdge requires preferredExit`,
1209
+ )
1210
+ }
1211
+ if (
1212
+ busSpec.exitEdge &&
1213
+ busSpec.preferredExit &&
1214
+ !borderTargetIncludesEdge(busSpec.preferredExit, busSpec.exitEdge)
1215
+ ) {
1216
+ throw new Error(
1217
+ `FanoutSolver: bus "${busSpec.busId}" exitEdge "${busSpec.exitEdge}" is incompatible with preferredExit "${busSpec.preferredExit}"`,
1218
+ )
1219
+ }
1034
1220
  const resolvedExit = resolveBusDirection({
1035
1221
  busId: busSpec.busId,
1036
1222
  explicitDirection:
@@ -1039,12 +1225,29 @@ export function prepareFanoutBuses(
1039
1225
  connections: preparedConnections,
1040
1226
  sharedBoundary,
1041
1227
  availableRegions:
1042
- busSpec.termination?.type === "plane" ? undefined : availableRegions,
1228
+ busSpec.termination?.type === "plane" || busSpec.exitEdge
1229
+ ? undefined
1230
+ : availableRegions,
1043
1231
  })
1232
+ if (
1233
+ busSpec.termination?.type !== "plane" &&
1234
+ busSpec.exitEdge &&
1235
+ resolvedExit.preferredExit &&
1236
+ availableRegions
1237
+ ) {
1238
+ validateExplicitExitAvailability({
1239
+ busId: busSpec.busId,
1240
+ exitEdge: busSpec.exitEdge,
1241
+ preferredExit: resolvedExit.preferredExit,
1242
+ availableRegions,
1243
+ })
1244
+ }
1044
1245
  buses.push({
1045
1246
  busId: busSpec.busId,
1046
1247
  direction: resolvedExit.direction,
1047
1248
  preferredExit: resolvedExit.preferredExit,
1249
+ ...(busSpec.exitEdge ? { exitEdge: busSpec.exitEdge } : {}),
1250
+ cornerBandConnectionCount: 0,
1048
1251
  allowedLayers: busSpec.allowedLayers,
1049
1252
  termination: busSpec.termination ?? { type: "boundary" },
1050
1253
  connections: preparedConnections,
@@ -1059,5 +1262,23 @@ export function prepareFanoutBuses(
1059
1262
  })
1060
1263
  }
1061
1264
 
1265
+ const cornerBandConnectionCounts = new Map<string, number>()
1266
+ for (const bus of buses) {
1267
+ const side = getCornerBandSide(bus.exitEdge, bus.preferredExit)
1268
+ if (!bus.exitEdge || !side) continue
1269
+ const key = `${bus.exitEdge}:${side}`
1270
+ cornerBandConnectionCounts.set(
1271
+ key,
1272
+ (cornerBandConnectionCounts.get(key) ?? 0) + bus.connections.length,
1273
+ )
1274
+ }
1275
+ for (const bus of buses) {
1276
+ const side = getCornerBandSide(bus.exitEdge, bus.preferredExit)
1277
+ if (!bus.exitEdge || !side) continue
1278
+ bus.cornerBandConnectionCount =
1279
+ cornerBandConnectionCounts.get(`${bus.exitEdge}:${side}`) ??
1280
+ bus.connections.length
1281
+ }
1282
+
1062
1283
  return buses
1063
1284
  }
package/lib/route-bus.ts CHANGED
@@ -3,6 +3,12 @@ import type {
3
3
  SimpleRouteJson,
4
4
  SimplifiedPcbTrace,
5
5
  } from "@tscircuit/capacity-autorouter"
6
+ import {
7
+ getCornerBandSide,
8
+ getDirectionForExitEdge,
9
+ getExitEdgeForDirection,
10
+ } from "./boundary-exit"
11
+ import { createFanoutOutputIds } from "./fanout-output-ids"
6
12
  import {
7
13
  distance,
8
14
  distancePointToObstacle,
@@ -10,7 +16,6 @@ import {
10
16
  distanceSegmentToObstacle,
11
17
  segmentsAreClear,
12
18
  } from "./geometry"
13
- import { createFanoutOutputIds } from "./fanout-output-ids"
14
19
  import { getAllRoutedTraceCopper } from "./get-routed-trace-copper"
15
20
  import { getLayerSpan } from "./layer-names"
16
21
  import {
@@ -72,6 +77,92 @@ function getPerpendicularAxis(
72
77
  return isHorizontal(direction) ? point.y : point.x
73
78
  }
74
79
 
80
+ function getCornerSide(bus: PreparedBus): "minimum" | "maximum" | undefined {
81
+ return getCornerBandSide(bus.exitEdge, bus.preferredExit)
82
+ }
83
+
84
+ function getLocalCornerSide(
85
+ bus: PreparedBus,
86
+ ): "minimum" | "maximum" | undefined {
87
+ return getCornerBandSide(
88
+ getExitEdgeForDirection(bus.direction),
89
+ bus.preferredExit,
90
+ )
91
+ }
92
+
93
+ function getCornerTargetTrack(params: {
94
+ bus: PreparedBus
95
+ connection: PreparedConnection
96
+ cornerExitLaneOffset: number
97
+ traceWidth: number
98
+ viaDiameter: number
99
+ clearance: number
100
+ }): number {
101
+ const {
102
+ bus,
103
+ connection,
104
+ cornerExitLaneOffset,
105
+ traceWidth,
106
+ viaDiameter,
107
+ clearance,
108
+ } = params
109
+ const side = getCornerSide(bus)
110
+ if (!side || !bus.exitEdge) {
111
+ return getPerpendicularAxis(
112
+ connection.exitTargetPoint ?? connection.targetPoint,
113
+ bus.direction,
114
+ )
115
+ }
116
+ const boundaryDirection = getDirectionForExitEdge(bus.exitEdge)
117
+ const boundaryMinimum = isHorizontal(boundaryDirection)
118
+ ? bus.sharedBoundary.minY
119
+ : bus.sharedBoundary.minX
120
+ const boundaryMaximum = isHorizontal(boundaryDirection)
121
+ ? bus.sharedBoundary.maxY
122
+ : bus.sharedBoundary.maxX
123
+ const pitch = Math.max(traceWidth + clearance, viaDiameter + clearance)
124
+ const bandCenter =
125
+ boundaryMinimum +
126
+ (boundaryMaximum - boundaryMinimum) * (side === "minimum" ? 0.25 : 0.75)
127
+ const bandConnectionCount = Math.max(
128
+ bus.connections.length,
129
+ bus.cornerBandConnectionCount ?? bus.connections.length,
130
+ )
131
+ const firstTrack = bandCenter - ((bandConnectionCount - 1) * pitch) / 2
132
+ const rank = getConnectionRank(bus, connection)
133
+ const globalSlot = cornerExitLaneOffset + rank
134
+ const reverseSlotOrder =
135
+ (side === "maximum") === directionSign(boundaryDirection) > 0
136
+ const orientedSlot = reverseSlotOrder
137
+ ? bandConnectionCount - 1 - globalSlot
138
+ : globalSlot
139
+ return firstTrack + orientedSlot * pitch
140
+ }
141
+
142
+ function getCornerLaneOffsets(
143
+ bus: PreparedBus,
144
+ acceptedPlans: readonly FanoutRoutePlan[],
145
+ ): { exit: number; localChannel: number; boundaryChannel: number } {
146
+ const side = getCornerSide(bus)
147
+ if (!side || !bus.exitEdge) {
148
+ return { exit: 0, localChannel: 0, boundaryChannel: 0 }
149
+ }
150
+ const cornerPlans = acceptedPlans.filter(
151
+ (plan) => plan.exitEdge && plan.cornerBandSide !== undefined,
152
+ )
153
+ const plansOnExitEdge = cornerPlans.filter(
154
+ (plan) => plan.exitEdge === bus.exitEdge && plan.cornerBandSide === side,
155
+ )
156
+ const plansOnLocalEdge = cornerPlans.filter(
157
+ (plan) => plan.direction === bus.direction,
158
+ )
159
+ return {
160
+ exit: plansOnExitEdge.length,
161
+ localChannel: plansOnLocalEdge.length,
162
+ boundaryChannel: plansOnExitEdge.length,
163
+ }
164
+ }
165
+
75
166
  function makePoint(
76
167
  axis: number,
77
168
  perpendicularAxis: number,
@@ -82,8 +173,11 @@ function makePoint(
82
173
  : { x: perpendicularAxis, y: axis }
83
174
  }
84
175
 
85
- function getExitAxis(bus: PreparedBus): number {
86
- switch (bus.direction) {
176
+ function getExitAxis(
177
+ bus: PreparedBus,
178
+ direction: FanoutDirection = bus.direction,
179
+ ): number {
180
+ switch (direction) {
87
181
  case "right":
88
182
  return bus.sharedBoundary.maxX
89
183
  case "left":
@@ -296,10 +390,12 @@ function getPreferredTrack(params: {
296
390
  connection: PreparedConnection
297
391
  traceWidth: number
298
392
  }): number {
299
- const preferredTrack = getPerpendicularAxis(
300
- params.connection.exitTargetPoint ?? params.connection.targetPoint,
301
- params.bus.direction,
302
- )
393
+ const preferredTrack = getCornerSide(params.bus)
394
+ ? getPerpendicularAxis(params.connection.sourcePoint, params.bus.direction)
395
+ : getPerpendicularAxis(
396
+ params.connection.exitTargetPoint ?? params.connection.targetPoint,
397
+ params.bus.direction,
398
+ )
303
399
  const boundaryMinimum = isHorizontal(params.bus.direction)
304
400
  ? params.bus.sharedBoundary.minY
305
401
  : params.bus.sharedBoundary.minX
@@ -494,6 +590,9 @@ function buildPlan(params: {
494
590
  viaHandedness: ViaHandedness
495
591
  interstitialEscape: boolean
496
592
  spreadLaneIndex: number
593
+ cornerExitLaneOffset: number
594
+ cornerLocalChannelLaneOffset: number
595
+ cornerBoundaryChannelLaneOffset: number
497
596
  clearance: number
498
597
  terminateAtVia: boolean
499
598
  }): FanoutRoutePlan {
@@ -510,6 +609,9 @@ function buildPlan(params: {
510
609
  viaHandedness,
511
610
  interstitialEscape,
512
611
  spreadLaneIndex,
612
+ cornerExitLaneOffset,
613
+ cornerLocalChannelLaneOffset,
614
+ cornerBoundaryChannelLaneOffset,
513
615
  clearance,
514
616
  terminateAtVia,
515
617
  } = params
@@ -567,9 +669,70 @@ function buildPlan(params: {
567
669
  ? getAxis(spreadPoint, bus.direction)
568
670
  : viaAxis + sign * Math.abs(track - viaPerpendicularAxis)
569
671
  const doglegPoint = makePoint(targetLayerDoglegAxis, track, bus.direction)
672
+ const cornerSide = getCornerSide(bus)
673
+ const boundaryDirection = bus.exitEdge
674
+ ? getDirectionForExitEdge(bus.exitEdge)
675
+ : bus.direction
676
+ const boundarySign = directionSign(boundaryDirection)
677
+ const boundaryExitAxis = getExitAxis(bus, boundaryDirection)
678
+ const cornerTrack =
679
+ cornerSide && bus.exitEdge
680
+ ? getCornerTargetTrack({
681
+ bus,
682
+ connection: preparedConnection,
683
+ cornerExitLaneOffset,
684
+ traceWidth,
685
+ viaDiameter,
686
+ clearance,
687
+ })
688
+ : track
689
+ const connectionRank = getConnectionRank(bus, preparedConnection)
690
+ const localCornerSide = getLocalCornerSide(bus)
691
+ const localChannelLaneIndex =
692
+ localCornerSide === "maximum"
693
+ ? cornerLocalChannelLaneOffset + connectionRank
694
+ : cornerLocalChannelLaneOffset +
695
+ bus.connections.length -
696
+ 1 -
697
+ connectionRank
698
+ const globalBoundarySlot = cornerBoundaryChannelLaneOffset + connectionRank
699
+ const boundaryBandConnectionCount = Math.max(
700
+ bus.connections.length,
701
+ bus.cornerBandConnectionCount ?? bus.connections.length,
702
+ )
703
+ const boundaryChannelLaneIndex =
704
+ boundarySign > 0
705
+ ? globalBoundarySlot
706
+ : boundaryBandConnectionCount - 1 - globalBoundarySlot
707
+ const channelInset = (laneIndex: number) =>
708
+ viaDiameter / 2 +
709
+ traceWidth / 2 +
710
+ clearance +
711
+ laneIndex * (traceWidth + clearance)
712
+ const localChannelAxis =
713
+ getExitAxis(bus, bus.direction) - sign * channelInset(localChannelLaneIndex)
714
+ const boundaryChannelAxis =
715
+ boundaryExitAxis - boundarySign * channelInset(boundaryChannelLaneIndex)
716
+ const localChannelSourcePoint = makePoint(
717
+ localChannelAxis,
718
+ track,
719
+ bus.direction,
720
+ )
721
+ const localChannelTargetPoint = makePoint(
722
+ boundaryChannelAxis,
723
+ localChannelAxis,
724
+ boundaryDirection,
725
+ )
726
+ const boundaryChannelTargetPoint = makePoint(
727
+ boundaryChannelAxis,
728
+ cornerTrack,
729
+ boundaryDirection,
730
+ )
570
731
  const exitPoint = terminateAtVia
571
732
  ? viaPoint
572
- : makePoint(exitAxis, track, bus.direction)
733
+ : cornerSide
734
+ ? makePoint(boundaryExitAxis, cornerTrack, boundaryDirection)
735
+ : makePoint(exitAxis, track, bus.direction)
573
736
  const segments: RoutedSegment[] = []
574
737
  const route: SimplifiedPcbTrace["route"] = []
575
738
 
@@ -631,12 +794,27 @@ function buildPlan(params: {
631
794
 
632
795
  const targetLayerPoints = terminateAtVia
633
796
  ? [viaPoint]
634
- : useNestedSpread
797
+ : cornerSide
635
798
  ? chamferOrthogonalPolyline(
636
- [viaPoint, spreadPoint, doglegPoint, exitPoint],
799
+ [
800
+ viaPoint,
801
+ ...(useNestedSpread ? [spreadPoint] : []),
802
+ doglegPoint,
803
+ localChannelSourcePoint,
804
+ ...(isHorizontal(bus.direction) !== isHorizontal(boundaryDirection)
805
+ ? [localChannelTargetPoint]
806
+ : []),
807
+ boundaryChannelTargetPoint,
808
+ exitPoint,
809
+ ],
637
810
  Math.max(traceWidth + clearance, traceWidth * 2),
638
811
  )
639
- : [viaPoint, doglegPoint, exitPoint]
812
+ : useNestedSpread
813
+ ? chamferOrthogonalPolyline(
814
+ [viaPoint, spreadPoint, doglegPoint, exitPoint],
815
+ Math.max(traceWidth + clearance, traceWidth * 2),
816
+ )
817
+ : [viaPoint, doglegPoint, exitPoint]
640
818
  for (let index = 1; index < targetLayerPoints.length; index++) {
641
819
  const previousPoint = targetLayerPoints[index - 1]!
642
820
  const nextPoint = targetLayerPoints[index]!
@@ -666,6 +844,8 @@ function buildPlan(params: {
666
844
  targetLayer,
667
845
  termination: bus.termination,
668
846
  direction: bus.direction,
847
+ ...(bus.exitEdge ? { exitEdge: bus.exitEdge } : {}),
848
+ ...(cornerSide ? { cornerBandSide: cornerSide } : {}),
669
849
  exitPoint,
670
850
  trace: {
671
851
  type: "pcb_trace",
@@ -1314,6 +1494,9 @@ function routePlaneTerminatedBus(
1314
1494
  viaHandedness,
1315
1495
  interstitialEscape: !pairChannelFitsVia,
1316
1496
  spreadLaneIndex: 0,
1497
+ cornerExitLaneOffset: 0,
1498
+ cornerLocalChannelLaneOffset: 0,
1499
+ cornerBoundaryChannelLaneOffset: 0,
1317
1500
  clearance,
1318
1501
  terminateAtVia: true,
1319
1502
  })
@@ -1415,6 +1598,7 @@ export function routeBusAlternatives(
1415
1598
 
1416
1599
  const alternatives: FanoutRoutePlan[][] = []
1417
1600
  const seenAlternativeKeys = new Set<string>()
1601
+ const cornerLaneOffsets = getCornerLaneOffsets(bus, acceptedPlans)
1418
1602
 
1419
1603
  const addAlternative = (plans: FanoutRoutePlan[]): void => {
1420
1604
  const key = plans
@@ -1501,6 +1685,9 @@ export function routeBusAlternatives(
1501
1685
  connectionRank,
1502
1686
  bus.connections.length - connectionRank - 1,
1503
1687
  ),
1688
+ cornerExitLaneOffset: cornerLaneOffsets.exit,
1689
+ cornerLocalChannelLaneOffset: cornerLaneOffsets.localChannel,
1690
+ cornerBoundaryChannelLaneOffset: cornerLaneOffsets.boundaryChannel,
1504
1691
  clearance,
1505
1692
  terminateAtVia: false,
1506
1693
  })
@@ -1510,7 +1697,7 @@ export function routeBusAlternatives(
1510
1697
  otherPlans: [...acceptedPlans, ...candidatePlans],
1511
1698
  staticClearanceCache,
1512
1699
  blockingBusCounts,
1513
- cacheKey: `boundary:${bus.busId}:${targetLayer}:${preparedConnection.connectionIndex}:${viaHandedness}:${trackIndex}`,
1700
+ cacheKey: `boundary:${bus.busId}:${targetLayer}:${preparedConnection.connectionIndex}:${viaHandedness}:${trackIndex}:${bus.exitEdge ?? "legacy"}:${cornerLaneOffsets.exit}:${cornerLaneOffsets.localChannel}:${cornerLaneOffsets.boundaryChannel}`,
1514
1701
  srj,
1515
1702
  sharedBoundary: bus.sharedBoundary,
1516
1703
  clearance,
@@ -3,13 +3,13 @@ import type {
3
3
  SimpleRouteJson,
4
4
  SimplifiedPcbTrace,
5
5
  } from "@tscircuit/capacity-autorouter"
6
+ import { createFanoutOutputIds } from "./fanout-output-ids"
6
7
  import {
7
8
  distance,
8
9
  distancePointToObstacle,
9
10
  distanceSegmentToObstacle,
10
11
  distanceSegmentToSegment,
11
12
  } from "./geometry"
12
- import { createFanoutOutputIds } from "./fanout-output-ids"
13
13
  import { type AvailableBoundaryRegion, getRegionAnchor } from "./prepare-buses"
14
14
  import type {
15
15
  FanoutDirection,
@@ -729,7 +729,6 @@ function routeDirectionGroup(params: {
729
729
  if (achievedFlow !== terminals.length) {
730
730
  if (FANOUT_FLOW_DEBUG_ENABLED) {
731
731
  const unmatchedConnections = terminals.flatMap((terminal, index) => {
732
- const terminalNode = terminalStart + index
733
732
  return terminalWasMatched(index)
734
733
  ? []
735
734
  : [terminal.item.connection.connection.name]
@@ -832,6 +831,7 @@ function buildPlan(route: FlowRoute, traceWidth: number): FanoutRoutePlan {
832
831
  targetLayer: "top",
833
832
  termination: item.bus.termination,
834
833
  direction: item.bus.direction,
834
+ ...(item.bus.exitEdge ? { exitEdge: item.bus.exitEdge } : {}),
835
835
  exitPoint: points.at(-1)!,
836
836
  trace: {
837
837
  type: "pcb_trace",
@@ -3,12 +3,12 @@ import type {
3
3
  SimpleRouteJson,
4
4
  SimplifiedPcbTrace,
5
5
  } from "@tscircuit/capacity-autorouter"
6
+ import { createFanoutOutputIds } from "./fanout-output-ids"
6
7
  import {
7
8
  distance,
8
9
  distanceSegmentToObstacle,
9
10
  distanceSegmentToSegment,
10
11
  } from "./geometry"
11
- import { createFanoutOutputIds } from "./fanout-output-ids"
12
12
  import type {
13
13
  FanoutBorderDistribution,
14
14
  FanoutCorner,
@@ -768,6 +768,7 @@ function buildPlan(path: RoutedPath): FanoutRoutePlan {
768
768
  targetLayer: "top",
769
769
  termination: item.bus.termination,
770
770
  direction: item.direction,
771
+ ...(item.bus.exitEdge ? { exitEdge: item.bus.exitEdge } : {}),
771
772
  exitPoint: points.at(-1)!,
772
773
  trace: {
773
774
  type: "pcb_trace",
package/lib/types.ts CHANGED
@@ -21,6 +21,35 @@ export type FanoutCorner =
21
21
 
22
22
  export type FanoutBorderTarget = FanoutEdge | FanoutCorner
23
23
 
24
+ /**
25
+ * An unambiguous fanout exit position.
26
+ *
27
+ * The `*side` prefix names the physical shared-boundary edge. A corner suffix
28
+ * names the local escape direction and band on that edge; a `_center` suffix
29
+ * escapes outward through the middle of that edge. `center` leaves all three
30
+ * exit fields unconstrained.
31
+ */
32
+ export type FanoutExitPosition =
33
+ | "topside_left"
34
+ | "topside_center"
35
+ | "topside_right"
36
+ | "rightside_top"
37
+ | "rightside_center"
38
+ | "rightside_bottom"
39
+ | "bottomside_right"
40
+ | "bottomside_center"
41
+ | "bottomside_left"
42
+ | "leftside_bottom"
43
+ | "leftside_center"
44
+ | "leftside_top"
45
+ | "center"
46
+
47
+ export interface FanoutExitPositionConfig {
48
+ direction?: FanoutDirection
49
+ preferredExit?: FanoutBorderTarget
50
+ exitEdge?: FanoutEdge
51
+ }
52
+
24
53
  /**
25
54
  * A directed region of the shared fanout boundary. Corner regions are named
26
55
  * after the edge they belong to, so `top_left` exits through the top edge and
@@ -74,8 +103,19 @@ export type FanoutBusTermination =
74
103
  export interface FanoutBusSpec extends SimpleRouteBus {
75
104
  /** Component whose connection endpoints should be escaped. */
76
105
  sourceComponentId?: string
106
+ /** Canonical local-escape, boundary-edge, and edge-band selection. */
107
+ exitPosition?: FanoutExitPosition
108
+ /** Direction used to leave the source pads locally. */
77
109
  direction?: FanoutDirection
78
110
  preferredExit?: FanoutBorderTarget
111
+ /**
112
+ * Physical boundary edge on which the fanout endpoints terminate.
113
+ *
114
+ * This is independent of `direction`: a bus may escape its pads `up`, use
115
+ * the `top-right` band, and terminate on the `right` edge. An explicit edge
116
+ * plus a corner-valued `preferredExit` enables the packed boundary channel.
117
+ */
118
+ exitEdge?: FanoutEdge
79
119
  /** Layers to which this bus is allowed to escape. */
80
120
  allowedLayers?: readonly string[]
81
121
  /**
@@ -246,6 +286,10 @@ export interface PreparedBus {
246
286
  busId: string
247
287
  direction: FanoutDirection
248
288
  preferredExit?: FanoutBorderTarget
289
+ /** Explicit final boundary edge. Omitted for legacy direction-based exits. */
290
+ exitEdge?: FanoutEdge
291
+ /** Total connection count sharing this explicit edge/corner band. */
292
+ cornerBandConnectionCount?: number
249
293
  /** Layers to which this bus is allowed to escape. */
250
294
  allowedLayers?: readonly string[]
251
295
  termination: FanoutBusTermination
@@ -287,7 +331,12 @@ export interface FanoutRoutePlan {
287
331
  targetPoint: ConnectionPoint
288
332
  targetLayer: string
289
333
  termination: FanoutBusTermination
334
+ /** Local pad escape direction. */
290
335
  direction: FanoutDirection
336
+ /** Explicit physical boundary edge used by a packed corner channel. */
337
+ exitEdge?: FanoutEdge
338
+ /** Lower/left or upper/right band reserved along `exitEdge`. */
339
+ cornerBandSide?: "minimum" | "maximum"
291
340
  exitPoint: Point2D
292
341
  trace: SimplifiedPcbTrace
293
342
  segments: RoutedSegment[]
@@ -3,6 +3,7 @@ import type {
3
3
  SimpleRouteJson,
4
4
  SimplifiedPcbTrace,
5
5
  } from "@tscircuit/capacity-autorouter"
6
+ import { getCornerBandSide } from "./boundary-exit"
6
7
  import {
7
8
  distance,
8
9
  distancePointToObstacle,
@@ -11,13 +12,14 @@ import {
11
12
  distanceSegmentToSegment,
12
13
  segmentsAreClear,
13
14
  } from "./geometry"
15
+ import { getAllRoutedTraceCopper } from "./get-routed-trace-copper"
14
16
  import {
15
17
  connectionsShareElectricalNet,
16
18
  obstacleSharesElectricalNet,
17
19
  } from "./net-identity"
18
- import { getAllRoutedTraceCopper } from "./get-routed-trace-copper"
19
20
  import type {
20
21
  Bounds,
22
+ FanoutEdge,
21
23
  FanoutRoutePlan,
22
24
  FanoutValidationIssue,
23
25
  FanoutValidationReport,
@@ -73,6 +75,29 @@ function pointIsOnBoundary(point: Point2D, boundary: Bounds): boolean {
73
75
  return inside && onEdge
74
76
  }
75
77
 
78
+ function pointIsOnBoundaryEdge(
79
+ point: Point2D,
80
+ edge: FanoutEdge,
81
+ boundary: Bounds,
82
+ ): boolean {
83
+ const inside =
84
+ point.x >= boundary.minX - EPSILON &&
85
+ point.x <= boundary.maxX + EPSILON &&
86
+ point.y >= boundary.minY - EPSILON &&
87
+ point.y <= boundary.maxY + EPSILON
88
+ if (!inside) return false
89
+ switch (edge) {
90
+ case "left":
91
+ return Math.abs(point.x - boundary.minX) <= EPSILON
92
+ case "right":
93
+ return Math.abs(point.x - boundary.maxX) <= EPSILON
94
+ case "top":
95
+ return Math.abs(point.y - boundary.maxY) <= EPSILON
96
+ case "bottom":
97
+ return Math.abs(point.y - boundary.minY) <= EPSILON
98
+ }
99
+ }
100
+
76
101
  function pointIsInsideBounds(point: Point2D, bounds: Bounds): boolean {
77
102
  return (
78
103
  point.x >= bounds.minX - EPSILON &&
@@ -236,6 +261,31 @@ function validatePlanStructure(params: {
236
261
  plan,
237
262
  )
238
263
  }
264
+ const expectedCornerBandSide = getCornerBandSide(
265
+ preparedBus?.exitEdge,
266
+ preparedBus?.preferredExit,
267
+ )
268
+ if (
269
+ preparedBus?.exitEdge !== plan.exitEdge ||
270
+ expectedCornerBandSide !== plan.cornerBandSide
271
+ ) {
272
+ addIssue(
273
+ issues,
274
+ "output-exit-mismatch",
275
+ `Plan ${plan.connectionName} does not retain its prepared boundary edge and band`,
276
+ plan,
277
+ )
278
+ } else if (
279
+ preparedBus?.exitEdge &&
280
+ !pointIsOnBoundaryEdge(plan.exitPoint, preparedBus.exitEdge, sharedBoundary)
281
+ ) {
282
+ addIssue(
283
+ issues,
284
+ "output-exit-mismatch",
285
+ `Plan ${plan.connectionName} does not terminate on its declared ${preparedBus.exitEdge} edge`,
286
+ plan,
287
+ )
288
+ }
239
289
  if (plan.segments.length === 0 || plan.length <= EPSILON) {
240
290
  addIssue(
241
291
  issues,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.34",
3
+ "version": "0.0.36",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",