@tscircuit/fanout-solver 0.0.38 → 0.0.40

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/geometry.ts CHANGED
@@ -98,6 +98,27 @@ export function pointIsInsideObstacle(
98
98
  )
99
99
  }
100
100
 
101
+ export function circleFitsInsideObstacle(params: {
102
+ center: Point2D
103
+ diameter: number
104
+ obstacle: Obstacle
105
+ tolerance?: number
106
+ }): boolean {
107
+ const { center, diameter, obstacle, tolerance = EPSILON } = params
108
+ const radius = diameter / 2
109
+ if (obstacleIsCircular(obstacle)) {
110
+ return (
111
+ distance(center, obstacle.center) + radius <=
112
+ obstacle.width / 2 + tolerance
113
+ )
114
+ }
115
+ const localCenter = toObstacleLocalPoint(center, obstacle)
116
+ return (
117
+ Math.abs(localCenter.x) + radius <= obstacle.width / 2 + tolerance &&
118
+ Math.abs(localCenter.y) + radius <= obstacle.height / 2 + tolerance
119
+ )
120
+ }
121
+
101
122
  export function distancePointToObstacle(
102
123
  point: Point2D,
103
124
  obstacle: Obstacle,
@@ -2,7 +2,7 @@ import type {
2
2
  SimpleRouteJson,
3
3
  SimplifiedPcbTrace,
4
4
  } from "@tscircuit/capacity-autorouter"
5
- import { getCopperLayerNames, getLayerSpan } from "./layer-names"
5
+ import { getCopperLayerNames, getRouteViaSpanLayers } from "./layer-names"
6
6
  import type { Point2D, RoutedSegment } from "./types"
7
7
 
8
8
  export interface RoutedTraceVia {
@@ -21,6 +21,7 @@ export interface RoutedTraceCopper {
21
21
  export function getRoutedTraceCopper(
22
22
  srj: SimpleRouteJson,
23
23
  trace: SimplifiedPcbTrace,
24
+ allowBlindAndBuriedVias = true,
24
25
  ): RoutedTraceCopper {
25
26
  const layerNames = getCopperLayerNames(srj.layerCount)
26
27
  const segments: RoutedSegment[] = []
@@ -39,11 +40,16 @@ export function getRoutedTraceCopper(
39
40
  srj.min_via_pad_diameter ??
40
41
  srj.minViaDiameter ??
41
42
  srj.minTraceWidth,
42
- spanLayers: getLayerSpan(
43
- routePoint.from_layer,
44
- routePoint.to_layer,
43
+ spanLayers: getRouteViaSpanLayers({
44
+ fromLayer: routePoint.from_layer,
45
+ toLayer: routePoint.to_layer,
46
+ layers:
47
+ "layers" in routePoint && Array.isArray(routePoint.layers)
48
+ ? (routePoint.layers as string[])
49
+ : undefined,
45
50
  layerNames,
46
- ),
51
+ allowBlindAndBuriedVias,
52
+ }),
47
53
  })
48
54
  previousWire = undefined
49
55
  continue
@@ -78,6 +84,9 @@ export function getRoutedTraceCopper(
78
84
 
79
85
  export function getAllRoutedTraceCopper(
80
86
  srj: SimpleRouteJson,
87
+ allowBlindAndBuriedVias = true,
81
88
  ): RoutedTraceCopper[] {
82
- return (srj.traces ?? []).map((trace) => getRoutedTraceCopper(srj, trace))
89
+ return (srj.traces ?? []).map((trace) =>
90
+ getRoutedTraceCopper(srj, trace, allowBlindAndBuriedVias),
91
+ )
83
92
  }
package/lib/index.ts CHANGED
@@ -24,10 +24,13 @@ export type {
24
24
  FanoutPlaneConnectivity,
25
25
  FanoutPlaneTermination,
26
26
  FanoutRoutePlan,
27
+ FanoutRoutePoint,
28
+ FanoutSimplifiedPcbTrace,
27
29
  FanoutSolverOptions,
28
30
  FanoutSolverOutput,
29
31
  FanoutValidationIssue,
30
32
  FanoutValidationReport,
33
+ FanoutViaRoutePoint,
31
34
  Point2D,
32
35
  PreparedBus,
33
36
  SimpleRouteJsonWithFanoutPlanes,
@@ -34,6 +34,46 @@ export function getLayerSpan(
34
34
  return layerNames.slice(firstIndex, lastIndex + 1)
35
35
  }
36
36
 
37
+ export function getViaSpanLayers(params: {
38
+ fromLayer: string
39
+ toLayer: string
40
+ layerNames: string[]
41
+ allowBlindAndBuriedVias: boolean
42
+ }): string[] {
43
+ const { fromLayer, toLayer, layerNames, allowBlindAndBuriedVias } = params
44
+ const logicalSpan = getLayerSpan(fromLayer, toLayer, layerNames)
45
+ return allowBlindAndBuriedVias ? logicalSpan : [...layerNames]
46
+ }
47
+
48
+ export function getRouteViaSpanLayers(params: {
49
+ fromLayer: string
50
+ toLayer: string
51
+ layers?: readonly string[]
52
+ layerNames: string[]
53
+ allowBlindAndBuriedVias: boolean
54
+ }): string[] {
55
+ const { fromLayer, toLayer, layers, layerNames, allowBlindAndBuriedVias } =
56
+ params
57
+ const configuredSpan = getViaSpanLayers({
58
+ fromLayer,
59
+ toLayer,
60
+ layerNames,
61
+ allowBlindAndBuriedVias,
62
+ })
63
+ if (!layers) return configuredSpan
64
+ if (
65
+ layers.length === 0 ||
66
+ !layers.includes(fromLayer) ||
67
+ !layers.includes(toLayer) ||
68
+ layers.some((layer) => !layerNames.includes(layer))
69
+ ) {
70
+ throw new Error(
71
+ `FanoutSolver: route via from "${fromLayer}" to "${toLayer}" has an invalid physical layer span`,
72
+ )
73
+ }
74
+ return [...layers]
75
+ }
76
+
37
77
  export function generateLayerAssignments(params: {
38
78
  busIds: string[]
39
79
  layers: string[]
@@ -42,21 +42,46 @@ function rebuildTraceRoute(
42
42
  const lastOriginalWire = plan.trace.route.findLast(
43
43
  (point) => point.route_type === "wire",
44
44
  )
45
+ const getWireMetadata = (
46
+ wire: typeof firstOriginalWire,
47
+ ): Partial<
48
+ Extract<SimplifiedPcbTrace["route"][number], { route_type: "wire" }>
49
+ > => {
50
+ if (wire?.route_type !== "wire") return {}
51
+ const metadata: Partial<
52
+ Extract<SimplifiedPcbTrace["route"][number], { route_type: "wire" }>
53
+ > = { ...wire }
54
+ delete metadata.route_type
55
+ delete metadata.x
56
+ delete metadata.y
57
+ delete metadata.width
58
+ delete metadata.layer
59
+ return metadata
60
+ }
61
+ const vias = getPlanVias(plan)
62
+ const startsWithSourceVia =
63
+ pointsMatch(firstSegment.start, plan.sourcePoint) &&
64
+ firstSegment.layer !== plan.sourceLayer &&
65
+ vias.some(
66
+ (via) =>
67
+ pointsMatch(via.center, firstSegment.start) &&
68
+ via.spanLayers.includes(plan.sourceLayer) &&
69
+ via.spanLayers.includes(firstSegment.layer),
70
+ )
71
+ const initialLayer = startsWithSourceVia
72
+ ? plan.sourceLayer
73
+ : firstSegment.layer
45
74
  const route: SimplifiedPcbTrace["route"] = [
46
75
  {
76
+ ...getWireMetadata(firstOriginalWire),
47
77
  route_type: "wire",
48
78
  ...firstSegment.start,
49
79
  width: firstSegment.width,
50
- layer: firstSegment.layer,
51
- ...(firstOriginalWire?.route_type === "wire" &&
52
- firstOriginalWire.start_pcb_port_id
53
- ? { start_pcb_port_id: firstOriginalWire.start_pcb_port_id }
54
- : {}),
80
+ layer: initialLayer,
55
81
  },
56
82
  ]
57
83
  let currentPoint = firstSegment.start
58
- let currentLayer = firstSegment.layer
59
- const vias = getPlanVias(plan)
84
+ let currentLayer = initialLayer
60
85
 
61
86
  for (const [segmentIndex, segment] of segments.entries()) {
62
87
  if (!pointsMatch(currentPoint, segment.start)) return null
@@ -85,15 +110,13 @@ function rebuildTraceRoute(
85
110
  currentLayer = segment.layer
86
111
  }
87
112
  route.push({
113
+ ...(segmentIndex === segments.length - 1
114
+ ? getWireMetadata(lastOriginalWire)
115
+ : {}),
88
116
  route_type: "wire",
89
117
  ...segment.end,
90
118
  width: segment.width,
91
119
  layer: segment.layer,
92
- ...(segmentIndex === segments.length - 1 &&
93
- lastOriginalWire?.route_type === "wire" &&
94
- lastOriginalWire.end_pcb_port_id
95
- ? { end_pcb_port_id: lastOriginalWire.end_pcb_port_id }
96
- : {}),
97
120
  })
98
121
  currentPoint = segment.end
99
122
  }
@@ -131,6 +154,67 @@ function pointIsOutsideDenseBounds(
131
154
  )
132
155
  }
133
156
 
157
+ function splitSegmentAtDenseBounds(params: {
158
+ segment: RoutedSegment
159
+ bounds: Bounds
160
+ margin: number
161
+ }): RoutedSegment[] {
162
+ const { segment, bounds, margin } = params
163
+ const expandedBounds = {
164
+ minX: bounds.minX - margin,
165
+ maxX: bounds.maxX + margin,
166
+ minY: bounds.minY - margin,
167
+ maxY: bounds.maxY + margin,
168
+ }
169
+ const deltaX = segment.end.x - segment.start.x
170
+ const deltaY = segment.end.y - segment.start.y
171
+ const splitParameters = [0, 1]
172
+ const addSplitParameter = (parameter: number): void => {
173
+ if (parameter <= EPSILON || parameter >= 1 - EPSILON) return
174
+ const point = {
175
+ x: segment.start.x + deltaX * parameter,
176
+ y: segment.start.y + deltaY * parameter,
177
+ }
178
+ if (
179
+ point.x < expandedBounds.minX - EPSILON ||
180
+ point.x > expandedBounds.maxX + EPSILON ||
181
+ point.y < expandedBounds.minY - EPSILON ||
182
+ point.y > expandedBounds.maxY + EPSILON
183
+ ) {
184
+ return
185
+ }
186
+ splitParameters.push(parameter)
187
+ }
188
+ if (Math.abs(deltaX) > EPSILON) {
189
+ addSplitParameter((expandedBounds.minX - segment.start.x) / deltaX)
190
+ addSplitParameter((expandedBounds.maxX - segment.start.x) / deltaX)
191
+ }
192
+ if (Math.abs(deltaY) > EPSILON) {
193
+ addSplitParameter((expandedBounds.minY - segment.start.y) / deltaY)
194
+ addSplitParameter((expandedBounds.maxY - segment.start.y) / deltaY)
195
+ }
196
+ const parameters = splitParameters
197
+ .toSorted((first, second) => first - second)
198
+ .filter(
199
+ (parameter, index, values) =>
200
+ index === 0 || Math.abs(parameter - values[index - 1]!) > EPSILON,
201
+ )
202
+ return parameters.slice(1).map((endParameter, index) => {
203
+ const startParameter = parameters[index]!
204
+ return {
205
+ ...segment,
206
+ start: {
207
+ x: segment.start.x + deltaX * startParameter,
208
+ y: segment.start.y + deltaY * startParameter,
209
+ },
210
+ end: {
211
+ x: segment.start.x + deltaX * endParameter,
212
+ y: segment.start.y + deltaY * endParameter,
213
+ },
214
+ }
215
+ })
216
+ }
217
+
134
218
  function getDenseCopperBounds(bus: PreparedBus): Bounds {
135
219
  return bus.componentObstacles.reduce<Bounds>(
136
220
  (bounds, obstacle) => ({
@@ -364,8 +448,18 @@ function createTunedPlanCandidates(params: {
364
448
  targetAddedLength: number
365
449
  clearance: number
366
450
  sharedBoundary: Bounds
451
+ allowInsideDenseBounds?: boolean
452
+ denseBoundarySplitApplied?: boolean
367
453
  }): FanoutRoutePlan[] {
368
- const { plan, bus, targetAddedLength, clearance, sharedBoundary } = params
454
+ const {
455
+ plan,
456
+ bus,
457
+ targetAddedLength,
458
+ clearance,
459
+ sharedBoundary,
460
+ allowInsideDenseBounds = false,
461
+ denseBoundarySplitApplied = false,
462
+ } = params
369
463
  const candidates: FanoutRoutePlan[] = []
370
464
  const denseCopperBounds = getDenseCopperBounds(bus)
371
465
  const denseMargin = plan.segments[0]?.width
@@ -405,6 +499,7 @@ function createTunedPlanCandidates(params: {
405
499
  continue
406
500
  }
407
501
  if (
502
+ !allowInsideDenseBounds &&
408
503
  points
409
504
  .slice(1, -1)
410
505
  .some(
@@ -447,7 +542,22 @@ function createTunedPlanCandidates(params: {
447
542
  }
448
543
  }
449
544
  }
450
- return candidates
545
+ if (denseBoundarySplitApplied) return candidates
546
+ const splitSegments = plan.segments.flatMap((segment) =>
547
+ splitSegmentAtDenseBounds({
548
+ segment,
549
+ bounds: denseCopperBounds,
550
+ margin: denseMargin,
551
+ }),
552
+ )
553
+ const splitPlan = createPlanWithSegments(plan, splitSegments)
554
+ if (!splitPlan) return candidates
555
+ const splitCandidates = createTunedPlanCandidates({
556
+ ...params,
557
+ plan: splitPlan,
558
+ denseBoundarySplitApplied: true,
559
+ })
560
+ return [...candidates, ...splitCandidates]
451
561
  }
452
562
 
453
563
  function getBusSkew(plans: readonly FanoutRoutePlan[]): number {
@@ -466,7 +576,19 @@ export function matchBusPlanLengths(params: {
466
576
  inputSrj: SimpleRouteJson
467
577
  sharedBoundary: Bounds
468
578
  clearance: number
579
+ allowBlindAndBuriedVias?: boolean
469
580
  allowSameNetMerges?: boolean
581
+ /**
582
+ * Allows tuning on target-layer copper inside the component pad envelope.
583
+ * Intended only for coordinated dense routing whose complete copper is
584
+ * revalidated and whose remaining dogbone capacity is checked atomically.
585
+ */
586
+ allowMatchingInsideDenseBounds?: boolean
587
+ /**
588
+ * Rejects a geometrically clear candidate when it would make a caller-owned
589
+ * downstream assignment (such as pending plane dogbones) infeasible.
590
+ */
591
+ candidatePlansAreFeasible?: (plans: readonly FanoutRoutePlan[]) => boolean
470
592
  }):
471
593
  | { plans: FanoutRoutePlan[]; failedBus?: never }
472
594
  | { plans: null; failedBus: PreparedBus } {
@@ -475,7 +597,10 @@ export function matchBusPlanLengths(params: {
475
597
  inputSrj,
476
598
  sharedBoundary,
477
599
  clearance,
600
+ allowBlindAndBuriedVias = true,
478
601
  allowSameNetMerges = false,
602
+ allowMatchingInsideDenseBounds = false,
603
+ candidatePlansAreFeasible,
479
604
  } = params
480
605
  let matchedPlans = [...params.plans]
481
606
  const constrainedBuses = preparedBuses.filter(
@@ -524,6 +649,95 @@ export function matchBusPlanLengths(params: {
524
649
  )
525
650
  .toSorted((first, second) => first - second)
526
651
  let acceptedPlans: FanoutRoutePlan[] | null = null
652
+ const acceptCandidate = (
653
+ candidate: FanoutRoutePlan,
654
+ ): FanoutRoutePlan[] | null => {
655
+ const nextPlans = matchedPlans.map((plan) =>
656
+ plan === shortest ? candidate : plan,
657
+ )
658
+ const nextBusPlans = nextPlans.filter(
659
+ (plan) => plan.busId === bus.busId,
660
+ )
661
+ if (getBusSkew(nextBusPlans) > skew + EPSILON) return null
662
+ if (
663
+ !fanoutPlansAreClear({
664
+ plans: nextPlans,
665
+ srj: inputSrj,
666
+ sharedBoundary,
667
+ clearance,
668
+ allowBlindAndBuriedVias,
669
+ allowSameNetMerges,
670
+ })
671
+ ) {
672
+ return null
673
+ }
674
+ if (
675
+ candidatePlansAreFeasible &&
676
+ !candidatePlansAreFeasible(nextPlans)
677
+ ) {
678
+ return null
679
+ }
680
+ return nextPlans
681
+ }
682
+ const findMultiSpanCandidate = (
683
+ targetAddedLength: number,
684
+ ): FanoutRoutePlan[] | null => {
685
+ const maximumSearchStates = 320
686
+ const maximumCandidatesPerState = 48
687
+ let searchedStateCount = 0
688
+ const sampleCandidates = (
689
+ candidates: readonly FanoutRoutePlan[],
690
+ ): FanoutRoutePlan[] => {
691
+ if (candidates.length <= maximumCandidatesPerState) {
692
+ return [...candidates]
693
+ }
694
+ return Array.from(
695
+ { length: maximumCandidatesPerState },
696
+ (_, sampleIndex) =>
697
+ candidates[
698
+ Math.floor(
699
+ (sampleIndex * candidates.length) / maximumCandidatesPerState,
700
+ )
701
+ ]!,
702
+ )
703
+ }
704
+ const search = (
705
+ currentPlan: FanoutRoutePlan,
706
+ stagesRemaining: number,
707
+ ): FanoutRoutePlan[] | null => {
708
+ const addedLength = currentPlan.length - shortest.length
709
+ const remainingAddition = targetAddedLength - addedLength
710
+ if (remainingAddition <= EPSILON) {
711
+ return acceptCandidate(currentPlan)
712
+ }
713
+ if (stagesRemaining <= 0) return null
714
+ const stageAddedLength = remainingAddition / stagesRemaining
715
+ const candidates = sampleCandidates(
716
+ createTunedPlanCandidates({
717
+ plan: currentPlan,
718
+ bus,
719
+ targetAddedLength: stageAddedLength,
720
+ clearance,
721
+ sharedBoundary: bus.sharedBoundary,
722
+ allowInsideDenseBounds: allowMatchingInsideDenseBounds,
723
+ }),
724
+ )
725
+ for (const candidate of candidates) {
726
+ searchedStateCount++
727
+ if (searchedStateCount > maximumSearchStates) return null
728
+ if (!acceptCandidate(candidate)) continue
729
+ const result = search(candidate, stagesRemaining - 1)
730
+ if (result) return result
731
+ }
732
+ return null
733
+ }
734
+ for (let stageCount = 2; stageCount <= 8; stageCount++) {
735
+ const result = search(shortest, stageCount)
736
+ if (result) return result
737
+ if (searchedStateCount > maximumSearchStates) break
738
+ }
739
+ return null
740
+ }
527
741
  for (const targetAddedLength of targetAddedLengths) {
528
742
  const candidates = createTunedPlanCandidates({
529
743
  plan: shortest,
@@ -531,30 +745,19 @@ export function matchBusPlanLengths(params: {
531
745
  targetAddedLength,
532
746
  clearance,
533
747
  sharedBoundary: bus.sharedBoundary,
748
+ allowInsideDenseBounds: allowMatchingInsideDenseBounds,
534
749
  })
535
750
  for (const candidate of candidates) {
536
- const nextPlans = matchedPlans.map((plan) =>
537
- plan === shortest ? candidate : plan,
538
- )
539
- const nextBusPlans = nextPlans.filter(
540
- (plan) => plan.busId === bus.busId,
541
- )
542
- const nextSkew = getBusSkew(nextBusPlans)
543
- if (nextSkew > skew + EPSILON) continue
544
- if (
545
- !fanoutPlansAreClear({
546
- plans: nextPlans,
547
- srj: inputSrj,
548
- sharedBoundary,
549
- clearance,
550
- allowSameNetMerges,
551
- })
552
- ) {
553
- continue
554
- }
555
- acceptedPlans = nextPlans
751
+ acceptedPlans = acceptCandidate(candidate)
752
+ if (!acceptedPlans) continue
556
753
  break
557
754
  }
755
+ if (
756
+ !acceptedPlans &&
757
+ Math.abs(targetAddedLength - minimumRequiredAddition) <= EPSILON
758
+ ) {
759
+ acceptedPlans = findMultiSpanCandidate(targetAddedLength)
760
+ }
558
761
  if (acceptedPlans) break
559
762
  }
560
763
  if (!acceptedPlans) return { plans: null, failedBus: bus }