@tscircuit/fanout-solver 0.0.36 → 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.
@@ -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
  }
@@ -819,7 +842,7 @@ function prepareConnection(params: {
819
842
  sourceGrid: ComponentGrid
820
843
  componentGrids: ComponentGrid[]
821
844
  termination: FanoutBusTermination
822
- exitTargetPoint?: { x: number; y: number }
845
+ exitTargetPoint?: { x: number; y: number; layer?: string }
823
846
  }): PreparedConnection {
824
847
  const {
825
848
  connection,
@@ -863,7 +886,11 @@ function prepareConnection(params: {
863
886
  sourceLayer,
864
887
  sourceObstacle: sourceMatch.obstacle,
865
888
  targetPoint,
866
- exitTargetPoint: exitTargetPoint ?? targetPoint,
889
+ exitTargetPoint: exitTargetPoint ?? {
890
+ x: targetPoint.x,
891
+ y: targetPoint.y,
892
+ },
893
+ hasExplicitLayeredExitTarget: exitTargetPoint?.layer !== undefined,
867
894
  }
868
895
  }
869
896
  throw new Error(
@@ -1244,6 +1271,9 @@ export function prepareFanoutBuses(
1244
1271
  }
1245
1272
  buses.push({
1246
1273
  busId: busSpec.busId,
1274
+ ...(busSpec.maxLengthSkew === undefined
1275
+ ? {}
1276
+ : { maxLengthSkew: busSpec.maxLengthSkew }),
1247
1277
  direction: resolvedExit.direction,
1248
1278
  preferredExit: resolvedExit.preferredExit,
1249
1279
  ...(busSpec.exitEdge ? { exitEdge: busSpec.exitEdge } : {}),