@tscircuit/fanout-solver 0.0.17 → 0.0.19

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/lib/route-bus.ts CHANGED
@@ -11,6 +11,10 @@ import {
11
11
  segmentsAreClear,
12
12
  } from "./geometry"
13
13
  import { getLayerSpan } from "./layer-names"
14
+ import {
15
+ connectionsShareElectricalNet,
16
+ obstacleSharesElectricalNet,
17
+ } from "./net-identity"
14
18
  import type {
15
19
  Bounds,
16
20
  FanoutDirection,
@@ -21,7 +25,9 @@ import type {
21
25
  RoutedSegment,
22
26
  } from "./types"
23
27
 
24
- interface RouteBusParams {
28
+ export type RouteBusStaticClearanceCache = Map<string, boolean>
29
+
30
+ export interface RouteBusParams {
25
31
  srj: SimpleRouteJson
26
32
  bus: PreparedBus
27
33
  targetLayer: string
@@ -32,6 +38,9 @@ interface RouteBusParams {
32
38
  viaHoleDiameter: number
33
39
  clearance: number
34
40
  compactBusTracks: boolean
41
+ allowSameNetMerges?: boolean
42
+ staticClearanceCache?: RouteBusStaticClearanceCache
43
+ blockingBusCounts?: Map<string, number>
35
44
  }
36
45
 
37
46
  interface TrackCandidate {
@@ -658,13 +667,29 @@ function segmentIsClearOfObstacles(params: {
658
667
  segment: RoutedSegment
659
668
  plan: FanoutRoutePlan
660
669
  segmentIndex: number
670
+ srj: SimpleRouteJson
671
+ allowSameNetMerges: boolean
661
672
  obstacles: Obstacle[]
662
673
  clearance: number
663
674
  }): boolean {
664
- const { segment, plan, segmentIndex, obstacles, clearance } = params
675
+ const {
676
+ segment,
677
+ plan,
678
+ segmentIndex,
679
+ srj,
680
+ allowSameNetMerges,
681
+ obstacles,
682
+ clearance,
683
+ } = params
665
684
  for (const obstacle of obstacles) {
666
685
  if (!obstacle.layers.includes(segment.layer)) continue
667
686
  if (obstacle.connectedTo.includes(plan.connectionName)) continue
687
+ if (
688
+ allowSameNetMerges &&
689
+ obstacleSharesElectricalNet(srj, obstacle, plan.connectionName)
690
+ ) {
691
+ continue
692
+ }
668
693
  if (
669
694
  segmentIndex === 0 &&
670
695
  obstacle === plan.sourceObstacle &&
@@ -682,14 +707,14 @@ function segmentIsClearOfObstacles(params: {
682
707
  return true
683
708
  }
684
709
 
685
- function planIsClear(params: {
710
+ function planIsStaticallyClear(params: {
686
711
  plan: FanoutRoutePlan
687
- otherPlans: FanoutRoutePlan[]
688
712
  srj: SimpleRouteJson
689
713
  sharedBoundary: Bounds
690
714
  clearance: number
715
+ allowSameNetMerges: boolean
691
716
  }): boolean {
692
- const { plan, otherPlans, srj, sharedBoundary, clearance } = params
717
+ const { plan, srj, sharedBoundary, clearance, allowSameNetMerges } = params
693
718
  const routableBounds = getRoutableBounds(srj.bounds, sharedBoundary)
694
719
  if (
695
720
  !pointIsInsideBounds(plan.exitPoint, routableBounds) ||
@@ -707,6 +732,8 @@ function planIsClear(params: {
707
732
  segment: plan.segments[index]!,
708
733
  plan,
709
734
  segmentIndex: index,
735
+ srj,
736
+ allowSameNetMerges,
710
737
  obstacles: srj.obstacles,
711
738
  clearance,
712
739
  })
@@ -721,6 +748,12 @@ function planIsClear(params: {
721
748
  ) {
722
749
  continue
723
750
  }
751
+ if (
752
+ allowSameNetMerges &&
753
+ obstacleSharesElectricalNet(srj, obstacle, plan.connectionName)
754
+ ) {
755
+ continue
756
+ }
724
757
  if (
725
758
  distancePointToObstacle(plan.via.center, obstacle) <
726
759
  plan.via.diameter / 2 + clearance - 1e-9
@@ -730,10 +763,55 @@ function planIsClear(params: {
730
763
  }
731
764
  }
732
765
 
766
+ return true
767
+ }
768
+
769
+ function planIsClearOfPlans(params: {
770
+ plan: FanoutRoutePlan
771
+ otherPlans: FanoutRoutePlan[]
772
+ srj: SimpleRouteJson
773
+ allowSameNetMerges: boolean
774
+ clearance: number
775
+ blockingBusCounts?: Map<string, number>
776
+ }): boolean {
777
+ const {
778
+ plan,
779
+ otherPlans,
780
+ srj,
781
+ allowSameNetMerges,
782
+ clearance,
783
+ blockingBusCounts,
784
+ } = params
733
785
  for (const otherPlan of otherPlans) {
786
+ if (
787
+ allowSameNetMerges &&
788
+ connectionsShareElectricalNet(
789
+ srj,
790
+ plan.connectionName,
791
+ otherPlan.connectionName,
792
+ )
793
+ ) {
794
+ continue
795
+ }
796
+ const plansShareSourcePort =
797
+ (plan.sourcePoint.pcb_port_id &&
798
+ plan.sourcePoint.pcb_port_id === otherPlan.sourcePoint.pcb_port_id) ||
799
+ (plan.sourcePoint.pointId &&
800
+ plan.sourcePoint.pointId === otherPlan.sourcePoint.pointId)
801
+ if (plansShareSourcePort) continue
802
+ const recordBlocker = (): void => {
803
+ if (otherPlan.busId === plan.busId) return
804
+ blockingBusCounts?.set(
805
+ otherPlan.busId,
806
+ (blockingBusCounts.get(otherPlan.busId) ?? 0) + 1,
807
+ )
808
+ }
734
809
  for (const segment of plan.segments) {
735
810
  for (const otherSegment of otherPlan.segments) {
736
- if (!segmentsAreClear(segment, otherSegment, clearance)) return false
811
+ if (!segmentsAreClear(segment, otherSegment, clearance)) {
812
+ recordBlocker()
813
+ return false
814
+ }
737
815
  }
738
816
  if (
739
817
  otherPlan.via?.spanLayers.includes(segment.layer) &&
@@ -744,6 +822,7 @@ function planIsClear(params: {
744
822
  ) <
745
823
  otherPlan.via.diameter / 2 + segment.width / 2 + clearance - 1e-9
746
824
  ) {
825
+ recordBlocker()
747
826
  return false
748
827
  }
749
828
  }
@@ -758,6 +837,7 @@ function planIsClear(params: {
758
837
  ) <
759
838
  plan.via.diameter / 2 + otherSegment.width / 2 + clearance - 1e-9
760
839
  ) {
840
+ recordBlocker()
761
841
  return false
762
842
  }
763
843
  }
@@ -769,6 +849,7 @@ function planIsClear(params: {
769
849
  distance(plan.via.center, otherPlan.via.center) <
770
850
  (plan.via.diameter + otherPlan.via.diameter) / 2 + clearance - 1e-9
771
851
  ) {
852
+ recordBlocker()
772
853
  return false
773
854
  }
774
855
  }
@@ -776,6 +857,99 @@ function planIsClear(params: {
776
857
  return true
777
858
  }
778
859
 
860
+ function planIsClear(params: {
861
+ plan: FanoutRoutePlan
862
+ otherPlans: FanoutRoutePlan[]
863
+ staticClearanceCache?: RouteBusStaticClearanceCache
864
+ blockingBusCounts?: Map<string, number>
865
+ cacheKey: string
866
+ srj: SimpleRouteJson
867
+ sharedBoundary: Bounds
868
+ clearance: number
869
+ allowSameNetMerges: boolean
870
+ }): boolean {
871
+ const {
872
+ plan,
873
+ otherPlans,
874
+ staticClearanceCache,
875
+ blockingBusCounts,
876
+ cacheKey,
877
+ srj,
878
+ sharedBoundary,
879
+ clearance,
880
+ allowSameNetMerges,
881
+ } = params
882
+ let staticallyClear = staticClearanceCache?.get(cacheKey)
883
+ if (staticallyClear === undefined) {
884
+ staticallyClear = planIsStaticallyClear({
885
+ plan,
886
+ srj,
887
+ sharedBoundary,
888
+ clearance,
889
+ allowSameNetMerges,
890
+ })
891
+ staticClearanceCache?.set(cacheKey, staticallyClear)
892
+ }
893
+ return (
894
+ staticallyClear &&
895
+ planIsClearOfPlans({
896
+ plan,
897
+ otherPlans,
898
+ srj,
899
+ allowSameNetMerges,
900
+ clearance,
901
+ blockingBusCounts,
902
+ })
903
+ )
904
+ }
905
+
906
+ /**
907
+ * Validate a complete set of fanout plans against the source SRJ and against
908
+ * one another. This is intentionally separate from route search so every
909
+ * route-producing strategy can share the same final safety invariant.
910
+ */
911
+ export function fanoutPlansAreClear(params: {
912
+ plans: readonly FanoutRoutePlan[]
913
+ srj: SimpleRouteJson
914
+ sharedBoundary: Bounds
915
+ clearance: number
916
+ allowSameNetMerges?: boolean
917
+ }): boolean {
918
+ const {
919
+ plans,
920
+ srj,
921
+ sharedBoundary,
922
+ clearance,
923
+ allowSameNetMerges = false,
924
+ } = params
925
+ for (let index = 0; index < plans.length; index++) {
926
+ const plan = plans[index]!
927
+ if (
928
+ !planIsStaticallyClear({
929
+ plan,
930
+ srj,
931
+ sharedBoundary,
932
+ clearance,
933
+ allowSameNetMerges,
934
+ })
935
+ ) {
936
+ return false
937
+ }
938
+ if (
939
+ !planIsClearOfPlans({
940
+ plan,
941
+ otherPlans: plans.filter((_, otherIndex) => otherIndex !== index),
942
+ srj,
943
+ allowSameNetMerges,
944
+ clearance,
945
+ })
946
+ ) {
947
+ return false
948
+ }
949
+ }
950
+ return true
951
+ }
952
+
779
953
  function routePlaneTerminatedBus(
780
954
  params: RouteBusParams,
781
955
  ): FanoutRoutePlan[] | null {
@@ -789,6 +963,9 @@ function routePlaneTerminatedBus(
789
963
  viaDiameter,
790
964
  viaHoleDiameter,
791
965
  clearance,
966
+ staticClearanceCache,
967
+ blockingBusCounts,
968
+ allowSameNetMerges = false,
792
969
  } = params
793
970
  const sourceObstacle = bus.connections[0]?.sourceObstacle
794
971
  if (!sourceObstacle || bus.termination.type !== "plane") return null
@@ -833,9 +1010,13 @@ function routePlaneTerminatedBus(
833
1010
  !planIsClear({
834
1011
  plan,
835
1012
  otherPlans: [...acceptedPlans, ...candidatePlans],
1013
+ staticClearanceCache,
1014
+ blockingBusCounts,
1015
+ cacheKey: `plane:${bus.busId}:${targetLayer}:${preparedConnection.connectionIndex}:${viaHandedness}`,
836
1016
  srj,
837
1017
  sharedBoundary: bus.sharedBoundary,
838
1018
  clearance,
1019
+ allowSameNetMerges,
839
1020
  })
840
1021
  ) {
841
1022
  orderIsClear = false
@@ -850,7 +1031,10 @@ function routePlaneTerminatedBus(
850
1031
  return null
851
1032
  }
852
1033
 
853
- export function routeBus(params: RouteBusParams): FanoutRoutePlan[] | null {
1034
+ export function routeBusAlternatives(
1035
+ params: RouteBusParams,
1036
+ maxAlternatives = 1,
1037
+ ): FanoutRoutePlan[][] {
854
1038
  const {
855
1039
  srj,
856
1040
  bus,
@@ -862,13 +1046,22 @@ export function routeBus(params: RouteBusParams): FanoutRoutePlan[] | null {
862
1046
  viaHoleDiameter,
863
1047
  clearance,
864
1048
  compactBusTracks,
1049
+ staticClearanceCache,
1050
+ blockingBusCounts,
1051
+ allowSameNetMerges = false,
865
1052
  } = params
1053
+ if (!Number.isInteger(maxAlternatives) || maxAlternatives < 1) {
1054
+ throw new Error(
1055
+ `FanoutSolver: maxAlternatives must be a positive integer, received ${maxAlternatives}`,
1056
+ )
1057
+ }
866
1058
  if (bus.termination.type === "plane") {
867
- return routePlaneTerminatedBus(params)
1059
+ const plan = routePlaneTerminatedBus(params)
1060
+ return plan ? [plan] : []
868
1061
  }
869
1062
  const exitAxis = getExitAxis(bus)
870
1063
  const sourceObstacle = bus.connections[0]?.sourceObstacle
871
- if (!sourceObstacle) return []
1064
+ if (!sourceObstacle) return [[]]
872
1065
  const directionalPadSize = isHorizontal(bus.direction)
873
1066
  ? sourceObstacle.width
874
1067
  : sourceObstacle.height
@@ -886,71 +1079,112 @@ export function routeBus(params: RouteBusParams): FanoutRoutePlan[] | null {
886
1079
  : [1, -1]
887
1080
  : [0]
888
1081
 
889
- for (const viaHandedness of viaHandednesses) {
890
- for (const connectionOrder of getConnectionOrders(bus)) {
891
- const candidatePlans: FanoutRoutePlan[] = []
892
- let orderIsClear = true
893
- for (const preparedConnection of connectionOrder) {
894
- let acceptedPlan: FanoutRoutePlan | null = null
895
- for (const track of getTrackCandidates({
896
- bus,
897
- connection: preparedConnection,
898
- preferredTrack: getPreferredTrack({
899
- bus,
900
- connection: preparedConnection,
901
- targetUsesVia,
902
- interstitialEscape,
903
- compactBusTracks,
904
- traceWidth,
905
- viaDiameter,
906
- clearance,
907
- }),
908
- traceWidth,
1082
+ const alternatives: FanoutRoutePlan[][] = []
1083
+ const seenAlternativeKeys = new Set<string>()
1084
+
1085
+ const addAlternative = (plans: FanoutRoutePlan[]): void => {
1086
+ const key = plans
1087
+ .map(
1088
+ (plan) =>
1089
+ `${plan.connectionIndex}:${plan.targetLayer}:${plan.exitPoint.x}:${plan.exitPoint.y}:${plan.segments.map((segment) => `${segment.start.x},${segment.start.y},${segment.end.x},${segment.end.y},${segment.layer}`).join(";")}`,
1090
+ )
1091
+ .join("|")
1092
+ if (seenAlternativeKeys.has(key)) return
1093
+ seenAlternativeKeys.add(key)
1094
+ alternatives.push(plans)
1095
+ }
1096
+
1097
+ const searchConnectionOrder = (
1098
+ connectionOrder: PreparedConnection[],
1099
+ viaHandedness: ViaHandedness,
1100
+ connectionIndex: number,
1101
+ candidatePlans: FanoutRoutePlan[],
1102
+ ): void => {
1103
+ if (alternatives.length >= maxAlternatives) return
1104
+ if (connectionIndex >= connectionOrder.length) {
1105
+ addAlternative(candidatePlans)
1106
+ return
1107
+ }
1108
+
1109
+ const preparedConnection = connectionOrder[connectionIndex]!
1110
+ const connectionRank = getConnectionRank(bus, preparedConnection)
1111
+ const trackCandidates = getTrackCandidates({
1112
+ bus,
1113
+ connection: preparedConnection,
1114
+ preferredTrack: getPreferredTrack({
1115
+ bus,
1116
+ connection: preparedConnection,
1117
+ targetUsesVia,
1118
+ interstitialEscape,
1119
+ compactBusTracks,
1120
+ traceWidth,
1121
+ viaDiameter,
1122
+ clearance,
1123
+ }),
1124
+ traceWidth,
1125
+ clearance,
1126
+ })
1127
+ for (
1128
+ let trackIndex = 0;
1129
+ trackIndex < trackCandidates.length;
1130
+ trackIndex++
1131
+ ) {
1132
+ const track = trackCandidates[trackIndex]!
1133
+ const plan = buildPlan({
1134
+ preparedConnection,
1135
+ bus,
1136
+ targetLayer,
1137
+ track: track.value,
1138
+ exitAxis,
1139
+ layerNames,
1140
+ traceWidth,
1141
+ viaDiameter,
1142
+ viaHoleDiameter,
1143
+ viaHandedness,
1144
+ interstitialEscape,
1145
+ spreadLaneIndex: Math.min(
1146
+ connectionRank,
1147
+ bus.connections.length - connectionRank - 1,
1148
+ ),
1149
+ clearance,
1150
+ terminateAtVia: false,
1151
+ })
1152
+ if (
1153
+ !planIsClear({
1154
+ plan,
1155
+ otherPlans: [...acceptedPlans, ...candidatePlans],
1156
+ staticClearanceCache,
1157
+ blockingBusCounts,
1158
+ cacheKey: `boundary:${bus.busId}:${targetLayer}:${preparedConnection.connectionIndex}:${viaHandedness}:${trackIndex}`,
1159
+ srj,
1160
+ sharedBoundary: bus.sharedBoundary,
909
1161
  clearance,
910
- })) {
911
- const plan = buildPlan({
912
- preparedConnection,
913
- bus,
914
- targetLayer,
915
- track: track.value,
916
- exitAxis,
917
- layerNames,
918
- traceWidth,
919
- viaDiameter,
920
- viaHoleDiameter,
921
- viaHandedness,
922
- interstitialEscape,
923
- spreadLaneIndex: Math.min(
924
- getConnectionRank(bus, preparedConnection),
925
- bus.connections.length -
926
- getConnectionRank(bus, preparedConnection) -
927
- 1,
928
- ),
929
- clearance,
930
- terminateAtVia: false,
931
- })
932
- if (
933
- planIsClear({
934
- plan,
935
- otherPlans: [...acceptedPlans, ...candidatePlans],
936
- srj,
937
- sharedBoundary: bus.sharedBoundary,
938
- clearance,
939
- })
940
- ) {
941
- acceptedPlan = plan
942
- break
943
- }
944
- }
945
- if (!acceptedPlan) {
946
- orderIsClear = false
947
- break
948
- }
949
- candidatePlans.push(acceptedPlan)
1162
+ allowSameNetMerges,
1163
+ })
1164
+ ) {
1165
+ continue
950
1166
  }
951
- if (orderIsClear) return candidatePlans
1167
+ searchConnectionOrder(
1168
+ connectionOrder,
1169
+ viaHandedness,
1170
+ connectionIndex + 1,
1171
+ [...candidatePlans, plan],
1172
+ )
1173
+ if (alternatives.length >= maxAlternatives) return
1174
+ if (maxAlternatives === 1) return
952
1175
  }
953
1176
  }
954
1177
 
955
- return null
1178
+ for (const viaHandedness of viaHandednesses) {
1179
+ for (const connectionOrder of getConnectionOrders(bus)) {
1180
+ searchConnectionOrder(connectionOrder, viaHandedness, 0, [])
1181
+ if (alternatives.length >= maxAlternatives) return alternatives
1182
+ }
1183
+ }
1184
+
1185
+ return alternatives
1186
+ }
1187
+
1188
+ export function routeBus(params: RouteBusParams): FanoutRoutePlan[] | null {
1189
+ return routeBusAlternatives(params, 1)[0] ?? null
956
1190
  }
package/lib/types.ts CHANGED
@@ -98,6 +98,8 @@ export interface FanoutSolverOptions {
98
98
  viaHoleDiameter?: number
99
99
  clearance?: number
100
100
  compactBusTracks?: boolean
101
+ /** Allow branches belonging to the same electrical net to share copper. */
102
+ allowSameNetMerges?: boolean
101
103
  singleLayerPushAndShove?: boolean
102
104
  /**
103
105
  * When preferred single-layer exits cannot coexist, allow a global
@@ -115,6 +117,7 @@ export interface FanoutAttemptSummary {
115
117
  routedConnectionCount: number
116
118
  failedBusIds: string[]
117
119
  score: number
120
+ validationIssues?: FanoutValidationIssue[]
118
121
  }
119
122
 
120
123
  export interface FanoutSolverOutput {
@@ -124,6 +127,42 @@ export interface FanoutSolverOutput {
124
127
  busLayerAssignments: Readonly<Record<string, string>>
125
128
  busDirections: Readonly<Record<string, FanoutDirection>>
126
129
  attempts: FanoutAttemptSummary[]
130
+ validation: FanoutValidationReport
131
+ }
132
+
133
+ export interface FanoutValidationIssue {
134
+ code:
135
+ | "missing-plan"
136
+ | "duplicate-plan"
137
+ | "unknown-plan"
138
+ | "connection-mismatch"
139
+ | "source-mismatch"
140
+ | "termination-mismatch"
141
+ | "not-broken-out"
142
+ | "outside-routing-bounds"
143
+ | "disconnected-trace"
144
+ | "unsupported-route-point"
145
+ | "trace-plan-mismatch"
146
+ | "output-connection-missing"
147
+ | "output-exit-mismatch"
148
+ | "downstream-endpoint-lost"
149
+ | "plane-connection-retained"
150
+ | "obstacle-clearance"
151
+ | "via-obstacle-clearance"
152
+ | "different-net-trace-clearance"
153
+ | "different-net-trace-via-clearance"
154
+ | "different-net-via-clearance"
155
+ message: string
156
+ connectionName?: string
157
+ otherConnectionName?: string
158
+ busId?: string
159
+ }
160
+
161
+ export interface FanoutValidationReport {
162
+ valid: boolean
163
+ checkedConnectionCount: number
164
+ brokenOutConnectionCount: number
165
+ issues: FanoutValidationIssue[]
127
166
  }
128
167
 
129
168
  export interface Point2D {