@tscircuit/fanout-solver 0.0.37 → 0.0.38

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
@@ -73,6 +73,10 @@ and treats each bus-layer decision atomically.
73
73
  visibly continuous pad connections.
74
74
  - Chamfers orthogonal routing corners into 45° segments before validating and
75
75
  emitting the fanout.
76
+ - Honors a boundary bus `maxLengthSkew` as a hard local-fanout constraint. It
77
+ adds straight/45° meanders only after the dense component escape, keeps the
78
+ original endpoints and vias, and atomically rejects an assignment when the
79
+ requested skew cannot fit inside that bus's shared boundary.
76
80
  - Verifies oriented-pad, via, trace, and already-routed fanout clearance on
77
81
  every complete candidate, independent of the routing strategy that produced
78
82
  it.
@@ -189,6 +193,7 @@ The canonical bus input is the current `SimpleRouteJson` bus structure:
189
193
  busId: "ddr",
190
194
  connectionNames: ["BUS_DDR_01", "BUS_DDR_02", "BUS_DDR_03"],
191
195
  preferredExit: "right",
196
+ maxLengthSkew: 0.25,
192
197
  },
193
198
  ],
194
199
  }
@@ -201,6 +206,13 @@ routed cleanly, the solver rejects that bus for the current layer assignment and
201
206
  tries another combination. `busExitPreferences` provides the same override
202
207
  without modifying the input object.
203
208
 
209
+ `maxLengthSkew` is measured in millimeters of planar routed copper within this
210
+ fanout phase. It is supported for multi-connection boundary buses. A loose or
211
+ omitted constraint leaves the routed geometry unchanged; an impossible
212
+ constraint fails instead of returning a fanout that violates the declared skew.
213
+ Plane-terminated buses reject `maxLengthSkew` because they do not have a
214
+ boundary tuning corridor.
215
+
204
216
  `availableCornersAndSides` is a solver-wide hard constraint. Its directed
205
217
  corner names distinguish the two edges meeting at a corner: `top_left` exits
206
218
  through the top edge, while `left_top` exits through the left edge. The complete
@@ -473,5 +485,7 @@ label.
473
485
  ## Scope
474
486
 
475
487
  This package owns the BGA pad-to-breakout prefix. It does not replace the
476
- board-level autorouter, length-match buses, or route arbitrary obstacles between
477
- the breakout boundary and the final destination.
488
+ board-level autorouter or route arbitrary obstacles between the breakout
489
+ boundary and the final destination. Its `maxLengthSkew` matching applies to the
490
+ local fanout prefix; end-to-end delay matching across multiple routing phases
491
+ still belongs to a board-level coordinator.
@@ -8,6 +8,7 @@ import {
8
8
  completeOriginalEndpoints,
9
9
  } from "./complete-original-endpoints"
10
10
  import { generateLayerAssignments, getCopperLayerNames } from "./layer-names"
11
+ import { matchBusPlanLengths } from "./match-bus-lengths"
11
12
  import {
12
13
  prepareFanoutBuses,
13
14
  resolveAvailableBoundaryRegions,
@@ -27,6 +28,7 @@ import type {
27
28
  FanoutRoutePlan,
28
29
  FanoutSolverOptions,
29
30
  FanoutSolverOutput,
31
+ FanoutValidationIssue,
30
32
  PreparedBus,
31
33
  } from "./types"
32
34
  import { validateFanoutSolution } from "./validate-fanout-solution"
@@ -482,6 +484,7 @@ export class FanoutSolver extends BaseSolver {
482
484
  private nextAssignmentIndex = 0
483
485
  private nextGeneratedAssignmentIndex = 0
484
486
  private bestAttempt: AssignmentAttempt | null = null
487
+ private lengthMatchingFailure: FanoutValidationIssue | null = null
485
488
  private endpointCompletion: CompleteOriginalEndpointsResult | null = null
486
489
 
487
490
  constructor(
@@ -658,6 +661,19 @@ export class FanoutSolver extends BaseSolver {
658
661
  })
659
662
  }
660
663
 
664
+ private matchCompletePlanLengths(
665
+ plans: readonly FanoutRoutePlan[],
666
+ ): ReturnType<typeof matchBusPlanLengths> {
667
+ return matchBusPlanLengths({
668
+ plans,
669
+ preparedBuses: this.preparedBuses,
670
+ inputSrj: this.inputSrj,
671
+ sharedBoundary: this.getValidationBoundary(),
672
+ clearance: this.config.clearance,
673
+ allowSameNetMerges: this.config.allowSameNetMerges,
674
+ })
675
+ }
676
+
661
677
  private evaluateAssignmentWithStrategy(
662
678
  assignmentIndex: number,
663
679
  busLayerAssignments: Readonly<Record<string, string>>,
@@ -784,6 +800,29 @@ export class FanoutSolver extends BaseSolver {
784
800
  }
785
801
 
786
802
  let validationIssues: FanoutAttemptSummary["validationIssues"]
803
+ if (plans.length === this.inputSrj.connections.length) {
804
+ const lengthMatching = this.matchCompletePlanLengths(plans)
805
+ if (lengthMatching.plans) {
806
+ plans = lengthMatching.plans
807
+ } else {
808
+ const constrainedBus = lengthMatching.failedBus
809
+ const lengthMatchingIssue: FanoutValidationIssue = {
810
+ code: "bus-length-skew",
811
+ message: `Bus ${constrainedBus.busId} could not satisfy its ${constrainedBus.maxLengthSkew!.toFixed(6)}mm routed-length skew within the fanout boundary`,
812
+ busId: constrainedBus.busId,
813
+ }
814
+ validationIssues = [lengthMatchingIssue]
815
+ this.lengthMatchingFailure ??= lengthMatchingIssue
816
+ plans = []
817
+ failedBusIds = [
818
+ constrainedBus.busId,
819
+ ...this.preparedBuses
820
+ .map((bus) => bus.busId)
821
+ .filter((busId) => busId !== constrainedBus.busId),
822
+ ]
823
+ blockingBusCounts.clear()
824
+ }
825
+ }
787
826
  let outputSrj = buildOutputSimpleRouteJson({
788
827
  inputSrj: this.inputSrj,
789
828
  plans,
@@ -1088,32 +1127,54 @@ export class FanoutSolver extends BaseSolver {
1088
1127
 
1089
1128
  let bestState: GroupedBeamState | undefined
1090
1129
  let outputSrj: SimpleRouteJson | undefined
1130
+ let bestMatchedScore = Number.POSITIVE_INFINITY
1131
+ let bestAdditionalViaCount = Number.POSITIVE_INFINITY
1132
+ const getCompleteStateScore = (state: GroupedBeamState): number =>
1133
+ state.plans.reduce((total, plan) => total + plan.length, 0) +
1134
+ getPlanViaCount(state.plans) * 0.1 +
1135
+ assignmentLoadPenalty(
1136
+ state.assignment,
1137
+ this.preparedBuses,
1138
+ this.config.balanceLayerLoadByConnectionCount,
1139
+ ) *
1140
+ getLayerLoadPenaltyWeight(this.config)
1141
+ const hasLengthConstraints = this.preparedBuses.some(
1142
+ (bus) => bus.maxLengthSkew !== undefined,
1143
+ )
1091
1144
  for (const state of states) {
1092
1145
  if (state.plans.length !== this.inputSrj.connections.length) continue
1146
+ const lengthMatching = this.matchCompletePlanLengths(state.plans)
1147
+ if (!lengthMatching.plans) continue
1148
+ const lengthMatchedPlans = lengthMatching.plans
1093
1149
  const candidateOutput = buildOutputSimpleRouteJson({
1094
1150
  inputSrj: this.inputSrj,
1095
- plans: state.plans,
1151
+ plans: lengthMatchedPlans,
1096
1152
  layerNames: this.config.layerNames,
1097
1153
  })
1098
- if (!this.validateCompletePlans(state.plans, candidateOutput).valid) {
1154
+ if (
1155
+ !this.validateCompletePlans(lengthMatchedPlans, candidateOutput).valid
1156
+ ) {
1099
1157
  continue
1100
1158
  }
1101
- bestState = state
1102
- outputSrj = candidateOutput
1103
- break
1159
+ const candidateState = { ...state, plans: lengthMatchedPlans }
1160
+ const candidateAdditionalViaCount =
1161
+ this.getCoordinatedAdditionalViaCount(lengthMatchedPlans)
1162
+ const candidateScore = getCompleteStateScore(candidateState)
1163
+ if (
1164
+ !bestState ||
1165
+ candidateAdditionalViaCount < bestAdditionalViaCount ||
1166
+ (candidateAdditionalViaCount === bestAdditionalViaCount &&
1167
+ candidateScore < bestMatchedScore)
1168
+ ) {
1169
+ bestState = candidateState
1170
+ outputSrj = candidateOutput
1171
+ bestMatchedScore = candidateScore
1172
+ bestAdditionalViaCount = candidateAdditionalViaCount
1173
+ }
1174
+ if (!hasLengthConstraints) break
1104
1175
  }
1105
1176
  if (!bestState || !outputSrj) return null
1106
- const score =
1107
- bestState.plans.length === this.inputSrj.connections.length
1108
- ? bestState.plans.reduce((total, plan) => total + plan.length, 0) +
1109
- getPlanViaCount(bestState.plans) * 0.1 +
1110
- assignmentLoadPenalty(
1111
- bestState.assignment,
1112
- this.preparedBuses,
1113
- this.config.balanceLayerLoadByConnectionCount,
1114
- ) *
1115
- getLayerLoadPenaltyWeight(this.config)
1116
- : Number.POSITIVE_INFINITY
1177
+ const score = bestMatchedScore
1117
1178
  if (!Number.isFinite(score)) return null
1118
1179
 
1119
1180
  const summary: FanoutAttemptSummary = {
@@ -1397,9 +1458,14 @@ export class FanoutSolver extends BaseSolver {
1397
1458
  this.solved = true
1398
1459
  } else {
1399
1460
  this.failed = true
1400
- this.error = this.bestAttempt
1401
- ? `FanoutSolver: best layer assignment routed ${this.bestAttempt.summary.routedConnectionCount}/${this.inputSrj.connections.length} connections`
1402
- : "FanoutSolver: no layer assignment could be evaluated"
1461
+ const validationMessage =
1462
+ this.lengthMatchingFailure?.message ??
1463
+ this.bestAttempt?.summary.validationIssues?.[0]?.message
1464
+ this.error = validationMessage
1465
+ ? `FanoutSolver: ${validationMessage}`
1466
+ : this.bestAttempt
1467
+ ? `FanoutSolver: best layer assignment routed ${this.bestAttempt.summary.routedConnectionCount}/${this.inputSrj.connections.length} connections`
1468
+ : "FanoutSolver: no layer assignment could be evaluated"
1403
1469
  }
1404
1470
  return
1405
1471
  }
@@ -0,0 +1,571 @@
1
+ import type {
2
+ SimpleRouteJson,
3
+ SimplifiedPcbTrace,
4
+ } from "@tscircuit/capacity-autorouter"
5
+ import {
6
+ distance,
7
+ distancePointToSegment,
8
+ distanceSegmentToSegment,
9
+ segmentsAreClear,
10
+ } from "./geometry"
11
+ import { fanoutPlansAreClear } from "./route-bus"
12
+ import type {
13
+ Bounds,
14
+ FanoutRoutePlan,
15
+ Point2D,
16
+ PreparedBus,
17
+ RoutedSegment,
18
+ RoutedVia,
19
+ } from "./types"
20
+
21
+ const EPSILON = 1e-6
22
+
23
+ function pointsMatch(first: Point2D, second: Point2D): boolean {
24
+ return distance(first, second) <= EPSILON
25
+ }
26
+
27
+ function getPlanVias(plan: FanoutRoutePlan): RoutedVia[] {
28
+ return [plan.via, ...(plan.additionalVias ?? [])].filter(
29
+ (via): via is RoutedVia => via !== undefined,
30
+ )
31
+ }
32
+
33
+ function rebuildTraceRoute(
34
+ plan: FanoutRoutePlan,
35
+ segments: readonly RoutedSegment[],
36
+ ): SimplifiedPcbTrace["route"] | null {
37
+ const firstSegment = segments[0]
38
+ if (!firstSegment) return null
39
+ const firstOriginalWire = plan.trace.route.find(
40
+ (point) => point.route_type === "wire",
41
+ )
42
+ const lastOriginalWire = plan.trace.route.findLast(
43
+ (point) => point.route_type === "wire",
44
+ )
45
+ const route: SimplifiedPcbTrace["route"] = [
46
+ {
47
+ route_type: "wire",
48
+ ...firstSegment.start,
49
+ 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
+ : {}),
55
+ },
56
+ ]
57
+ let currentPoint = firstSegment.start
58
+ let currentLayer = firstSegment.layer
59
+ const vias = getPlanVias(plan)
60
+
61
+ for (const [segmentIndex, segment] of segments.entries()) {
62
+ if (!pointsMatch(currentPoint, segment.start)) return null
63
+ if (currentLayer !== segment.layer) {
64
+ const transitionVia = vias.find(
65
+ (via) =>
66
+ pointsMatch(via.center, segment.start) &&
67
+ via.spanLayers.includes(currentLayer) &&
68
+ via.spanLayers.includes(segment.layer),
69
+ )
70
+ if (!transitionVia) return null
71
+ route.push({
72
+ route_type: "via",
73
+ ...segment.start,
74
+ from_layer: currentLayer,
75
+ to_layer: segment.layer,
76
+ via_diameter: transitionVia.diameter,
77
+ via_hole_diameter: transitionVia.holeDiameter,
78
+ })
79
+ route.push({
80
+ route_type: "wire",
81
+ ...segment.start,
82
+ width: segment.width,
83
+ layer: segment.layer,
84
+ })
85
+ currentLayer = segment.layer
86
+ }
87
+ route.push({
88
+ route_type: "wire",
89
+ ...segment.end,
90
+ width: segment.width,
91
+ 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
+ })
98
+ currentPoint = segment.end
99
+ }
100
+ return route
101
+ }
102
+
103
+ function createPlanWithSegments(
104
+ plan: FanoutRoutePlan,
105
+ segments: RoutedSegment[],
106
+ ): FanoutRoutePlan | null {
107
+ const route = rebuildTraceRoute(plan, segments)
108
+ if (!route) return null
109
+ const length = [...segments, ...(plan.planeEndpointSegments ?? [])].reduce(
110
+ (total, segment) => total + distance(segment.start, segment.end),
111
+ 0,
112
+ )
113
+ return {
114
+ ...plan,
115
+ trace: { ...plan.trace, route },
116
+ segments,
117
+ length,
118
+ }
119
+ }
120
+
121
+ function pointIsOutsideDenseBounds(
122
+ point: Point2D,
123
+ bounds: Bounds,
124
+ margin: number,
125
+ ): boolean {
126
+ return (
127
+ point.x < bounds.minX - margin ||
128
+ point.x > bounds.maxX + margin ||
129
+ point.y < bounds.minY - margin ||
130
+ point.y > bounds.maxY + margin
131
+ )
132
+ }
133
+
134
+ function getDenseCopperBounds(bus: PreparedBus): Bounds {
135
+ return bus.componentObstacles.reduce<Bounds>(
136
+ (bounds, obstacle) => ({
137
+ minX: Math.min(bounds.minX, obstacle.center.x - obstacle.width / 2),
138
+ maxX: Math.max(bounds.maxX, obstacle.center.x + obstacle.width / 2),
139
+ minY: Math.min(bounds.minY, obstacle.center.y - obstacle.height / 2),
140
+ maxY: Math.max(bounds.maxY, obstacle.center.y + obstacle.height / 2),
141
+ }),
142
+ {
143
+ minX: Number.POSITIVE_INFINITY,
144
+ maxX: Number.NEGATIVE_INFINITY,
145
+ minY: Number.POSITIVE_INFINITY,
146
+ maxY: Number.NEGATIVE_INFINITY,
147
+ },
148
+ )
149
+ }
150
+
151
+ function hasNonAdjacentSelfIntersection(
152
+ segments: readonly RoutedSegment[],
153
+ ): boolean {
154
+ for (let firstIndex = 0; firstIndex < segments.length; firstIndex++) {
155
+ const first = segments[firstIndex]!
156
+ for (
157
+ let secondIndex = firstIndex + 2;
158
+ secondIndex < segments.length;
159
+ secondIndex++
160
+ ) {
161
+ const second = segments[secondIndex]!
162
+ if (first.layer !== second.layer) continue
163
+ if (
164
+ secondIndex === firstIndex + 2 &&
165
+ pointsMatch(first.end, second.start)
166
+ ) {
167
+ continue
168
+ }
169
+ if (
170
+ distanceSegmentToSegment(
171
+ first.start,
172
+ first.end,
173
+ second.start,
174
+ second.end,
175
+ ) <= EPSILON
176
+ ) {
177
+ return true
178
+ }
179
+ }
180
+ }
181
+ return false
182
+ }
183
+
184
+ function replacementCopperIsSelfClear(params: {
185
+ plan: FanoutRoutePlan
186
+ segments: readonly RoutedSegment[]
187
+ replacementStartIndex: number
188
+ replacementSegmentCount: number
189
+ clearance: number
190
+ }): boolean {
191
+ const {
192
+ plan,
193
+ segments,
194
+ replacementStartIndex,
195
+ replacementSegmentCount,
196
+ clearance,
197
+ } = params
198
+ const replacementEndIndex =
199
+ replacementStartIndex + replacementSegmentCount - 1
200
+ const vias = getPlanVias(plan)
201
+ const getConnectedPathDistance = (
202
+ firstIndex: number,
203
+ secondIndex: number,
204
+ ): number => {
205
+ if (firstIndex === secondIndex) return 0
206
+ const startIndex = Math.min(firstIndex, secondIndex)
207
+ const endIndex = Math.max(firstIndex, secondIndex)
208
+ const startSegment = segments[startIndex]!
209
+ const endSegment = segments[endIndex]!
210
+ if (startSegment.layer !== endSegment.layer) {
211
+ return Number.POSITIVE_INFINITY
212
+ }
213
+ let currentPoint = startSegment.end
214
+ let pathDistance = 0
215
+ for (let index = startIndex + 1; index < endIndex; index++) {
216
+ const segment = segments[index]!
217
+ if (
218
+ segment.layer !== startSegment.layer ||
219
+ !pointsMatch(currentPoint, segment.start)
220
+ ) {
221
+ return Number.POSITIVE_INFINITY
222
+ }
223
+ pathDistance += distance(segment.start, segment.end)
224
+ currentPoint = segment.end
225
+ }
226
+ return pointsMatch(currentPoint, segments[endIndex]!.start)
227
+ ? pathDistance
228
+ : Number.POSITIVE_INFINITY
229
+ }
230
+ for (
231
+ let replacementIndex = replacementStartIndex;
232
+ replacementIndex <= replacementEndIndex;
233
+ replacementIndex++
234
+ ) {
235
+ const replacement = segments[replacementIndex]!
236
+ for (const [otherIndex, other] of segments.entries()) {
237
+ const requiredCenterlineClearance =
238
+ replacement.width / 2 + other.width / 2 + clearance
239
+ if (
240
+ getConnectedPathDistance(replacementIndex, otherIndex) <=
241
+ requiredCenterlineClearance + EPSILON
242
+ ) {
243
+ continue
244
+ }
245
+ if (!segmentsAreClear(replacement, other, clearance)) return false
246
+ }
247
+ for (const via of vias) {
248
+ if (!via.spanLayers.includes(replacement.layer)) continue
249
+ if (
250
+ pointsMatch(via.center, replacement.start) ||
251
+ pointsMatch(via.center, replacement.end)
252
+ ) {
253
+ continue
254
+ }
255
+ if (
256
+ distancePointToSegment(via.center, replacement.start, replacement.end) <
257
+ via.diameter / 2 + replacement.width / 2 + clearance - EPSILON
258
+ ) {
259
+ return false
260
+ }
261
+ }
262
+ }
263
+ return true
264
+ }
265
+
266
+ function pointIsInsideBounds(point: Point2D, bounds: Bounds): boolean {
267
+ return (
268
+ point.x >= bounds.minX - EPSILON &&
269
+ point.x <= bounds.maxX + EPSILON &&
270
+ point.y >= bounds.minY - EPSILON &&
271
+ point.y <= bounds.maxY + EPSILON
272
+ )
273
+ }
274
+
275
+ function addScaled(point: Point2D, vector: Point2D, scale: number): Point2D {
276
+ return {
277
+ x: point.x + vector.x * scale,
278
+ y: point.y + vector.y * scale,
279
+ }
280
+ }
281
+
282
+ function createMeanderPoints(params: {
283
+ segment: RoutedSegment
284
+ toothCount: number
285
+ targetAddedLength: number
286
+ pitch: number
287
+ placementFraction: number
288
+ normalSign: -1 | 1
289
+ }): Point2D[] | null {
290
+ const {
291
+ segment,
292
+ toothCount,
293
+ targetAddedLength,
294
+ pitch,
295
+ placementFraction,
296
+ normalSign,
297
+ } = params
298
+ const dx = segment.end.x - segment.start.x
299
+ const dy = segment.end.y - segment.start.y
300
+ const segmentLength = Math.hypot(dx, dy)
301
+ if (segmentLength <= EPSILON) return null
302
+ const isAxisAligned = Math.abs(dx) <= EPSILON || Math.abs(dy) <= EPSILON
303
+ const isFortyFiveDegree = Math.abs(Math.abs(dx) - Math.abs(dy)) <= EPSILON
304
+ if (!isAxisAligned && !isFortyFiveDegree) return null
305
+ const tangent = { x: dx / segmentLength, y: dy / segmentLength }
306
+ const normal = {
307
+ x: -tangent.y * normalSign,
308
+ y: tangent.x * normalSign,
309
+ }
310
+ const chamfer = Math.min(
311
+ pitch / 2,
312
+ targetAddedLength / (8 * toothCount * (Math.SQRT2 - 1)),
313
+ )
314
+ const plateau = pitch
315
+ const toothSpan = chamfer * 4 + plateau
316
+ const toothGap = pitch
317
+ const occupiedLength =
318
+ toothCount * toothSpan + Math.max(0, toothCount - 1) * toothGap
319
+ const minimumLead = pitch / 4
320
+ if (occupiedLength + minimumLead * 2 > segmentLength + EPSILON) {
321
+ return null
322
+ }
323
+ const minimumAddedLengthPerTooth = 4 * chamfer * (Math.SQRT2 - 1)
324
+ if (targetAddedLength + EPSILON < minimumAddedLengthPerTooth * toothCount) {
325
+ return null
326
+ }
327
+ const verticalRun =
328
+ (targetAddedLength / toothCount - minimumAddedLengthPerTooth) / 2
329
+ const movableLead = segmentLength - occupiedLength - minimumLead * 2
330
+ const leadingLength =
331
+ minimumLead + Math.max(0, movableLead) * placementFraction
332
+ let cursor = addScaled(segment.start, tangent, leadingLength)
333
+ const points: Point2D[] = [{ ...segment.start }, { ...cursor }]
334
+
335
+ for (let toothIndex = 0; toothIndex < toothCount; toothIndex++) {
336
+ cursor = addScaled(addScaled(cursor, tangent, chamfer), normal, chamfer)
337
+ points.push(cursor)
338
+ cursor = addScaled(cursor, normal, verticalRun)
339
+ points.push(cursor)
340
+ cursor = addScaled(addScaled(cursor, tangent, chamfer), normal, chamfer)
341
+ points.push(cursor)
342
+ cursor = addScaled(cursor, tangent, plateau)
343
+ points.push(cursor)
344
+ cursor = addScaled(addScaled(cursor, tangent, chamfer), normal, -chamfer)
345
+ points.push(cursor)
346
+ cursor = addScaled(cursor, normal, -verticalRun)
347
+ points.push(cursor)
348
+ cursor = addScaled(addScaled(cursor, tangent, chamfer), normal, -chamfer)
349
+ points.push(cursor)
350
+ if (toothIndex < toothCount - 1) {
351
+ cursor = addScaled(cursor, tangent, toothGap)
352
+ points.push(cursor)
353
+ }
354
+ }
355
+ points.push({ ...segment.end })
356
+ return points.filter(
357
+ (point, index) => index === 0 || !pointsMatch(point, points[index - 1]!),
358
+ )
359
+ }
360
+
361
+ function createTunedPlanCandidates(params: {
362
+ plan: FanoutRoutePlan
363
+ bus: PreparedBus
364
+ targetAddedLength: number
365
+ clearance: number
366
+ sharedBoundary: Bounds
367
+ }): FanoutRoutePlan[] {
368
+ const { plan, bus, targetAddedLength, clearance, sharedBoundary } = params
369
+ const candidates: FanoutRoutePlan[] = []
370
+ const denseCopperBounds = getDenseCopperBounds(bus)
371
+ const denseMargin = plan.segments[0]?.width
372
+ ? plan.segments[0].width / 2 + clearance
373
+ : clearance
374
+ const eligibleSegments = plan.segments
375
+ .map((segment, segmentIndex) => ({ segment, segmentIndex }))
376
+ .filter(({ segment }) => segment.layer === plan.targetLayer)
377
+ .toSorted(
378
+ (first, second) =>
379
+ distance(second.segment.start, second.segment.end) -
380
+ distance(first.segment.start, first.segment.end),
381
+ )
382
+
383
+ for (const { segment, segmentIndex } of eligibleSegments) {
384
+ const pitch = segment.width + clearance
385
+ const segmentLength = distance(segment.start, segment.end)
386
+ const maximumToothCount = Math.min(
387
+ 12,
388
+ Math.max(0, Math.floor((segmentLength / pitch + 0.5) / 4)),
389
+ )
390
+ for (let toothCount = 1; toothCount <= maximumToothCount; toothCount++) {
391
+ for (const placementFraction of [0.5, 0, 1, 0.25, 0.75]) {
392
+ for (const normalSign of [1, -1] as const) {
393
+ const points = createMeanderPoints({
394
+ segment,
395
+ toothCount,
396
+ targetAddedLength,
397
+ pitch,
398
+ placementFraction,
399
+ normalSign,
400
+ })
401
+ if (!points) continue
402
+ if (
403
+ points.some((point) => !pointIsInsideBounds(point, sharedBoundary))
404
+ ) {
405
+ continue
406
+ }
407
+ if (
408
+ points
409
+ .slice(1, -1)
410
+ .some(
411
+ (point) =>
412
+ !pointIsOutsideDenseBounds(
413
+ point,
414
+ denseCopperBounds,
415
+ denseMargin,
416
+ ),
417
+ )
418
+ ) {
419
+ continue
420
+ }
421
+ const replacementSegments = points.slice(1).map((end, index) => ({
422
+ start: points[index]!,
423
+ end,
424
+ width: segment.width,
425
+ layer: segment.layer,
426
+ }))
427
+ const segments = [
428
+ ...plan.segments.slice(0, segmentIndex),
429
+ ...replacementSegments,
430
+ ...plan.segments.slice(segmentIndex + 1),
431
+ ]
432
+ if (hasNonAdjacentSelfIntersection(segments)) continue
433
+ if (
434
+ !replacementCopperIsSelfClear({
435
+ plan,
436
+ segments,
437
+ replacementStartIndex: segmentIndex,
438
+ replacementSegmentCount: replacementSegments.length,
439
+ clearance,
440
+ })
441
+ ) {
442
+ continue
443
+ }
444
+ const candidate = createPlanWithSegments(plan, segments)
445
+ if (candidate) candidates.push(candidate)
446
+ }
447
+ }
448
+ }
449
+ }
450
+ return candidates
451
+ }
452
+
453
+ function getBusSkew(plans: readonly FanoutRoutePlan[]): number {
454
+ const lengths = plans.map((plan) => plan.length)
455
+ return Math.max(...lengths) - Math.min(...lengths)
456
+ }
457
+
458
+ /**
459
+ * Adds straight/45-degree meanders after the dense component escape. Matching
460
+ * is atomic: a constrained bus either satisfies its declared skew with the
461
+ * complete fanout copper still clear, or the complete assignment is rejected.
462
+ */
463
+ export function matchBusPlanLengths(params: {
464
+ plans: readonly FanoutRoutePlan[]
465
+ preparedBuses: readonly PreparedBus[]
466
+ inputSrj: SimpleRouteJson
467
+ sharedBoundary: Bounds
468
+ clearance: number
469
+ allowSameNetMerges?: boolean
470
+ }):
471
+ | { plans: FanoutRoutePlan[]; failedBus?: never }
472
+ | { plans: null; failedBus: PreparedBus } {
473
+ const {
474
+ preparedBuses,
475
+ inputSrj,
476
+ sharedBoundary,
477
+ clearance,
478
+ allowSameNetMerges = false,
479
+ } = params
480
+ let matchedPlans = [...params.plans]
481
+ const constrainedBuses = preparedBuses.filter(
482
+ (bus) => bus.maxLengthSkew !== undefined && bus.connections.length > 1,
483
+ )
484
+ if (constrainedBuses.length === 0) return { plans: matchedPlans }
485
+
486
+ for (const bus of constrainedBuses) {
487
+ if (bus.termination.type !== "boundary") {
488
+ return { plans: null, failedBus: bus }
489
+ }
490
+ const maximumIterations = bus.connections.length * 2
491
+ for (let iteration = 0; iteration < maximumIterations; iteration++) {
492
+ const busPlans = matchedPlans.filter((plan) => plan.busId === bus.busId)
493
+ if (busPlans.length !== bus.connections.length) {
494
+ return { plans: null, failedBus: bus }
495
+ }
496
+ const maxLengthSkew = bus.maxLengthSkew!
497
+ const skew = getBusSkew(busPlans)
498
+ if (skew <= maxLengthSkew + EPSILON) break
499
+ const shortest = busPlans.toSorted(
500
+ (first, second) =>
501
+ first.length - second.length ||
502
+ first.connectionName.localeCompare(second.connectionName),
503
+ )[0]!
504
+ const longestLength = Math.max(...busPlans.map((plan) => plan.length))
505
+ const deficit = longestLength - shortest.length
506
+ const minimumRequiredAddition = Math.max(
507
+ EPSILON,
508
+ deficit - maxLengthSkew + EPSILON,
509
+ )
510
+ const targetAddedLengths = [
511
+ minimumRequiredAddition,
512
+ minimumRequiredAddition + maxLengthSkew * 0.1,
513
+ deficit - maxLengthSkew * 0.5,
514
+ deficit - maxLengthSkew * 0.75,
515
+ deficit,
516
+ deficit + maxLengthSkew,
517
+ ]
518
+ .filter(
519
+ (value, index, values) =>
520
+ value > EPSILON &&
521
+ values.findIndex(
522
+ (candidate) => Math.abs(candidate - value) < EPSILON,
523
+ ) === index,
524
+ )
525
+ .toSorted((first, second) => first - second)
526
+ let acceptedPlans: FanoutRoutePlan[] | null = null
527
+ for (const targetAddedLength of targetAddedLengths) {
528
+ const candidates = createTunedPlanCandidates({
529
+ plan: shortest,
530
+ bus,
531
+ targetAddedLength,
532
+ clearance,
533
+ sharedBoundary: bus.sharedBoundary,
534
+ })
535
+ 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
556
+ break
557
+ }
558
+ if (acceptedPlans) break
559
+ }
560
+ if (!acceptedPlans) return { plans: null, failedBus: bus }
561
+ matchedPlans = acceptedPlans
562
+ }
563
+ const matchedBusPlans = matchedPlans.filter(
564
+ (plan) => plan.busId === bus.busId,
565
+ )
566
+ if (getBusSkew(matchedBusPlans) > bus.maxLengthSkew! + EPSILON) {
567
+ return { plans: null, failedBus: bus }
568
+ }
569
+ }
570
+ return { plans: matchedPlans }
571
+ }
@@ -580,6 +580,19 @@ function resolveAllowedLayers(
580
580
  return [...new Set(allowedLayers)]
581
581
  }
582
582
 
583
+ function resolveMaxLengthSkew(
584
+ busId: string,
585
+ value: number | undefined,
586
+ ): number | undefined {
587
+ if (value === undefined) return undefined
588
+ if (!Number.isFinite(value) || value < 0) {
589
+ throw new Error(
590
+ `FanoutSolver: bus "${busId}" maxLengthSkew must be a finite non-negative number`,
591
+ )
592
+ }
593
+ return value
594
+ }
595
+
583
596
  export function resolveAvailableBoundaryRegions(
584
597
  value: readonly FanoutAvailableCornerAndSideInput[] | undefined,
585
598
  ): AvailableBoundaryRegion[] | undefined {
@@ -667,6 +680,15 @@ function resolveBusSpecs(
667
680
  requestedBus.busId,
668
681
  (requestedBus as FanoutBusSpec).allowedLayers,
669
682
  )
683
+ const maxLengthSkew = resolveMaxLengthSkew(
684
+ requestedBus.busId,
685
+ requestedBus.maxLengthSkew,
686
+ )
687
+ if (termination.type === "plane" && maxLengthSkew !== undefined) {
688
+ throw new Error(
689
+ `FanoutSolver: plane-terminated bus "${requestedBus.busId}" cannot specify maxLengthSkew`,
690
+ )
691
+ }
670
692
  if (
671
693
  termination.type === "plane" &&
672
694
  resolvedExitFields.preferredExit !== undefined
@@ -682,6 +704,7 @@ function resolveBusSpecs(
682
704
  options.sourceComponentId,
683
705
  ...resolvedExitFields,
684
706
  ...(allowedLayers === undefined ? {} : { allowedLayers }),
707
+ ...(maxLengthSkew === undefined ? {} : { maxLengthSkew }),
685
708
  termination,
686
709
  })
687
710
  }
@@ -1248,6 +1271,9 @@ export function prepareFanoutBuses(
1248
1271
  }
1249
1272
  buses.push({
1250
1273
  busId: busSpec.busId,
1274
+ ...(busSpec.maxLengthSkew === undefined
1275
+ ? {}
1276
+ : { maxLengthSkew: busSpec.maxLengthSkew }),
1251
1277
  direction: resolvedExit.direction,
1252
1278
  preferredExit: resolvedExit.preferredExit,
1253
1279
  ...(busSpec.exitEdge ? { exitEdge: busSpec.exitEdge } : {}),
package/lib/types.ts CHANGED
@@ -250,6 +250,8 @@ export interface FanoutValidationIssue {
250
250
  | "different-net-trace-clearance"
251
251
  | "different-net-trace-via-clearance"
252
252
  | "different-net-via-clearance"
253
+ | "plan-length-mismatch"
254
+ | "bus-length-skew"
253
255
  message: string
254
256
  connectionName?: string
255
257
  otherConnectionName?: string
@@ -291,6 +293,8 @@ export interface PreparedConnection {
291
293
 
292
294
  export interface PreparedBus {
293
295
  busId: string
296
+ /** Maximum permitted routed copper length difference in millimeters. */
297
+ maxLengthSkew?: number
294
298
  direction: FanoutDirection
295
299
  preferredExit?: FanoutBorderTarget
296
300
  /** Explicit final boundary edge. Omitted for legacy direction-based exits. */
@@ -360,6 +360,19 @@ function validatePlanStructure(params: {
360
360
  }
361
361
  }
362
362
 
363
+ const measuredLength = [
364
+ ...plan.segments,
365
+ ...(plan.planeEndpointSegments ?? []),
366
+ ].reduce((total, segment) => total + distance(segment.start, segment.end), 0)
367
+ if (Math.abs(measuredLength - plan.length) > 1e-6) {
368
+ addIssue(
369
+ issues,
370
+ "plan-length-mismatch",
371
+ `Plan ${plan.connectionName} declares ${plan.length.toFixed(6)}mm but contains ${measuredLength.toFixed(6)}mm of routed copper`,
372
+ plan,
373
+ )
374
+ }
375
+
363
376
  const traceSegments = extractTraceSegments({
364
377
  trace: plan.trace,
365
378
  plan,
@@ -921,6 +934,21 @@ export function validateFanoutSolution(params: {
921
934
  connectionPlans.push(plan)
922
935
  plansByConnection.set(plan.connectionName, connectionPlans)
923
936
  }
937
+ for (const bus of preparedBuses) {
938
+ if (bus.maxLengthSkew === undefined) continue
939
+ const busPlans = plans.filter((plan) => plan.busId === bus.busId)
940
+ if (busPlans.length < 2) continue
941
+ const lengths = busPlans.map((plan) => plan.length)
942
+ const skew = Math.max(...lengths) - Math.min(...lengths)
943
+ if (skew > bus.maxLengthSkew + 1e-6) {
944
+ addIssue(
945
+ issues,
946
+ "bus-length-skew",
947
+ `Bus ${bus.busId} has ${skew.toFixed(6)}mm routed-length skew; ${bus.maxLengthSkew.toFixed(6)}mm is allowed`,
948
+ busPlans[0],
949
+ )
950
+ }
951
+ }
924
952
 
925
953
  for (const connection of inputSrj.connections) {
926
954
  const connectionPlans = plansByConnection.get(connection.name) ?? []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/fanout-solver",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
4
4
  "description": "BGA fanout solver with coordinated bus-layer escapes for SimpleRouteJson",
5
5
  "module": "lib/index.ts",
6
6
  "type": "module",