@tscircuit/fanout-solver 0.0.10

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,1233 @@
1
+ import type {
2
+ Obstacle,
3
+ SimpleRouteJson,
4
+ SimplifiedPcbTrace,
5
+ } from "@tscircuit/capacity-autorouter"
6
+ import {
7
+ distance,
8
+ distanceSegmentToObstacle,
9
+ distanceSegmentToSegment,
10
+ } from "./geometry"
11
+ import type {
12
+ FanoutBorderDistribution,
13
+ FanoutCorner,
14
+ FanoutDirection,
15
+ FanoutRoutePlan,
16
+ Point2D,
17
+ PreparedBus,
18
+ PreparedConnection,
19
+ RoutedSegment,
20
+ } from "./types"
21
+
22
+ interface PushShoveParams {
23
+ srj: SimpleRouteJson
24
+ buses: PreparedBus[]
25
+ traceWidth: number
26
+ clearance: number
27
+ borderDistribution: FanoutBorderDistribution
28
+ }
29
+
30
+ interface RoutingItem {
31
+ bus: PreparedBus
32
+ connection: PreparedConnection
33
+ direction: FanoutDirection
34
+ source: Point2D
35
+ cornerChannelPrefixes?: Point2D[][]
36
+ }
37
+
38
+ interface RoutedPath {
39
+ item: RoutingItem
40
+ points: Point2D[]
41
+ segments: RoutedSegment[]
42
+ }
43
+
44
+ interface ActiveRoute {
45
+ item: RoutingItem
46
+ points: Point2D[]
47
+ track: number
48
+ }
49
+
50
+ function isHorizontal(direction: FanoutDirection): boolean {
51
+ return direction === "left" || direction === "right"
52
+ }
53
+
54
+ function directionSign(direction: FanoutDirection): number {
55
+ return direction === "right" || direction === "up" ? 1 : -1
56
+ }
57
+
58
+ function getAxis(point: Point2D, direction: FanoutDirection): number {
59
+ return isHorizontal(direction) ? point.x : point.y
60
+ }
61
+
62
+ function getPerpendicularAxis(
63
+ point: Point2D,
64
+ direction: FanoutDirection,
65
+ ): number {
66
+ return isHorizontal(direction) ? point.y : point.x
67
+ }
68
+
69
+ function makePoint(
70
+ axis: number,
71
+ perpendicularAxis: number,
72
+ direction: FanoutDirection,
73
+ ): Point2D {
74
+ return isHorizontal(direction)
75
+ ? { x: axis, y: perpendicularAxis }
76
+ : { x: perpendicularAxis, y: axis }
77
+ }
78
+
79
+ function getExitAxis(bus: PreparedBus): number {
80
+ switch (bus.direction) {
81
+ case "right":
82
+ return bus.sharedBoundary.maxX
83
+ case "left":
84
+ return bus.sharedBoundary.minX
85
+ case "up":
86
+ return bus.sharedBoundary.maxY
87
+ case "down":
88
+ return bus.sharedBoundary.minY
89
+ }
90
+ }
91
+
92
+ function compressPath(points: Point2D[]): Point2D[] {
93
+ if (points.length < 3) return points
94
+ const compressed = [points[0]!]
95
+ for (let index = 1; index < points.length - 1; index++) {
96
+ const previous = compressed.at(-1)!
97
+ const current = points[index]!
98
+ const next = points[index + 1]!
99
+ const incoming = {
100
+ x: Math.sign(current.x - previous.x),
101
+ y: Math.sign(current.y - previous.y),
102
+ }
103
+ const outgoing = {
104
+ x: Math.sign(next.x - current.x),
105
+ y: Math.sign(next.y - current.y),
106
+ }
107
+ if (incoming.x !== outgoing.x || incoming.y !== outgoing.y) {
108
+ compressed.push(current)
109
+ }
110
+ }
111
+ compressed.push(points.at(-1)!)
112
+ return compressed
113
+ }
114
+
115
+ function getPathSegments(
116
+ points: Point2D[],
117
+ traceWidth: number,
118
+ ): RoutedSegment[] {
119
+ const segments: RoutedSegment[] = []
120
+ for (let index = 1; index < points.length; index++) {
121
+ if (distance(points[index - 1]!, points[index]!) < 1e-9) continue
122
+ segments.push({
123
+ start: points[index - 1]!,
124
+ end: points[index]!,
125
+ width: traceWidth,
126
+ layer: "top",
127
+ })
128
+ }
129
+ return segments
130
+ }
131
+
132
+ /**
133
+ * Finds a same-layer 45-degree channel for a pad enclosed by its own package.
134
+ * The diagonal advances equally along the directional and perpendicular axes,
135
+ * clears every other obstacle, and leaves enough runway for a straight segment
136
+ * before any completion dogleg.
137
+ */
138
+ function getCornerChannelPrefixes(params: {
139
+ srj: SimpleRouteJson
140
+ bus: PreparedBus
141
+ connection: PreparedConnection
142
+ traceWidth: number
143
+ clearance: number
144
+ }): Point2D[][] | null {
145
+ const { srj, bus, connection, traceWidth, clearance } = params
146
+ const direction = bus.direction
147
+ const sign = directionSign(direction)
148
+ const source = {
149
+ x: connection.sourcePoint.x,
150
+ y: connection.sourcePoint.y,
151
+ }
152
+ const sourceAxis = getAxis(source, direction)
153
+ const sourceTrack = getPerpendicularAxis(source, direction)
154
+ const requiredObstacleDistance = traceWidth / 2 + clearance
155
+ const componentExitAxis = (() => {
156
+ switch (direction) {
157
+ case "right":
158
+ return bus.componentBounds.maxX + requiredObstacleDistance
159
+ case "left":
160
+ return bus.componentBounds.minX - requiredObstacleDistance
161
+ case "up":
162
+ return bus.componentBounds.maxY + requiredObstacleDistance
163
+ case "down":
164
+ return bus.componentBounds.minY - requiredObstacleDistance
165
+ }
166
+ })()
167
+ const obstacleDistanceForSegment = (segment: RoutedSegment): number =>
168
+ Math.min(
169
+ ...srj.obstacles
170
+ .filter(
171
+ (obstacle) =>
172
+ obstacle !== connection.sourceObstacle &&
173
+ obstacle.layers.includes("top"),
174
+ )
175
+ .map((obstacle) => distanceSegmentToObstacle(segment, obstacle)),
176
+ )
177
+ const directSegment: RoutedSegment = {
178
+ start: source,
179
+ end: makePoint(componentExitAxis, sourceTrack, direction),
180
+ width: traceWidth,
181
+ layer: "top",
182
+ }
183
+ if (
184
+ obstacleDistanceForSegment(directSegment) >=
185
+ requiredObstacleDistance - 1e-9
186
+ ) {
187
+ return null
188
+ }
189
+
190
+ const perpendicularBounds = isHorizontal(direction)
191
+ ? {
192
+ minimum: bus.componentBounds.minY,
193
+ maximum: bus.componentBounds.maxY,
194
+ }
195
+ : {
196
+ minimum: bus.componentBounds.minX,
197
+ maximum: bus.componentBounds.maxX,
198
+ }
199
+ const directionalDistance = sign * (componentExitAxis - sourceAxis)
200
+ const targetTrack = getPerpendicularAxis(connection.targetPoint, direction)
201
+ const lanePitch = traceWidth + clearance
202
+ const exitAxis = getExitAxis(bus)
203
+ const candidates = ([-1, 1] as const)
204
+ .map((perpendicularSign) => {
205
+ const perpendicularExit =
206
+ perpendicularSign < 0
207
+ ? perpendicularBounds.minimum - requiredObstacleDistance
208
+ : perpendicularBounds.maximum + requiredObstacleDistance
209
+ const perpendicularDistance =
210
+ perpendicularSign * (perpendicularExit - sourceTrack)
211
+ const diagonalDistance = Math.max(
212
+ directionalDistance,
213
+ perpendicularDistance,
214
+ )
215
+ const diagonalEnd = makePoint(
216
+ sourceAxis + sign * diagonalDistance,
217
+ sourceTrack + perpendicularSign * diagonalDistance,
218
+ direction,
219
+ )
220
+ if (
221
+ sign * (exitAxis - getAxis(diagonalEnd, direction)) <
222
+ lanePitch - 1e-9
223
+ ) {
224
+ return null
225
+ }
226
+ const points = [source, diagonalEnd]
227
+ const withinBoundary = points.every(
228
+ (point) =>
229
+ point.x >= bus.sharedBoundary.minX - 1e-9 &&
230
+ point.x <= bus.sharedBoundary.maxX + 1e-9 &&
231
+ point.y >= bus.sharedBoundary.minY - 1e-9 &&
232
+ point.y <= bus.sharedBoundary.maxY + 1e-9,
233
+ )
234
+ if (!withinBoundary) return null
235
+ const obstacleDistance = Math.min(
236
+ ...getPathSegments(points, traceWidth).map(obstacleDistanceForSegment),
237
+ )
238
+ if (obstacleDistance < requiredObstacleDistance - 1e-9) return null
239
+ return {
240
+ points,
241
+ obstacleDistance,
242
+ targetDistance: Math.abs(
243
+ getPerpendicularAxis(diagonalEnd, direction) - targetTrack,
244
+ ),
245
+ }
246
+ })
247
+ .filter((candidate) => candidate !== null)
248
+ .toSorted(
249
+ (first, second) =>
250
+ second.obstacleDistance - first.obstacleDistance ||
251
+ first.targetDistance - second.targetDistance,
252
+ )
253
+
254
+ return candidates.length > 0
255
+ ? candidates.map((candidate) => candidate.points)
256
+ : null
257
+ }
258
+
259
+ function completeCornerChannelRoute(params: {
260
+ item: RoutingItem
261
+ srj: SimpleRouteJson
262
+ acceptedSegments: RoutedSegment[]
263
+ traceWidth: number
264
+ clearance: number
265
+ }): ActiveRoute | null {
266
+ const { item, srj, acceptedSegments, traceWidth, clearance } = params
267
+ const prefixes = item.cornerChannelPrefixes
268
+ if (!prefixes) return null
269
+ const direction = item.direction
270
+ const sign = directionSign(direction)
271
+ const exitAxis = getExitAxis(item.bus)
272
+ const lanePitch = traceWidth + clearance
273
+ const requiredObstacleDistance = traceWidth / 2 + clearance
274
+ const boundaryMinimum = isHorizontal(direction)
275
+ ? item.bus.sharedBoundary.minY
276
+ : item.bus.sharedBoundary.minX
277
+ const boundaryMaximum = isHorizontal(direction)
278
+ ? item.bus.sharedBoundary.maxY
279
+ : item.bus.sharedBoundary.maxX
280
+ const minimumTrack = boundaryMinimum + traceWidth / 2
281
+ const maximumTrack = boundaryMaximum - traceWidth / 2
282
+ const targetTrack = getPerpendicularAxis(
283
+ item.connection.targetPoint,
284
+ direction,
285
+ )
286
+ const trackStep = lanePitch / 2
287
+ const trackCandidates = new Set<number>([
288
+ Math.max(minimumTrack, Math.min(maximumTrack, targetTrack)),
289
+ ])
290
+ for (
291
+ let track = Math.ceil(minimumTrack / trackStep) * trackStep;
292
+ track <= maximumTrack + 1e-9;
293
+ track += trackStep
294
+ ) {
295
+ trackCandidates.add(Number(track.toFixed(9)))
296
+ }
297
+ const orderedTracks = [...trackCandidates].toSorted(
298
+ (first, second) =>
299
+ Math.abs(first - targetTrack) - Math.abs(second - targetTrack),
300
+ )
301
+
302
+ for (const prefix of prefixes) {
303
+ const diagonalEnd = prefix.at(-1)!
304
+ const diagonalTrack = getPerpendicularAxis(diagonalEnd, direction)
305
+ for (const track of orderedTracks) {
306
+ const shift = Math.abs(track - diagonalTrack)
307
+ const straightEnd = makePoint(
308
+ getAxis(diagonalEnd, direction) + sign * lanePitch,
309
+ diagonalTrack,
310
+ direction,
311
+ )
312
+ const doglegEnd = makePoint(
313
+ getAxis(straightEnd, direction) + sign * shift,
314
+ track,
315
+ direction,
316
+ )
317
+ if (sign * (exitAxis - getAxis(doglegEnd, direction)) < -1e-9) {
318
+ continue
319
+ }
320
+ const boundaryPoint = makePoint(exitAxis, track, direction)
321
+ const completionPoints = [
322
+ ...prefix,
323
+ straightEnd,
324
+ ...(shift > 1e-9 ? [doglegEnd] : []),
325
+ ]
326
+ if (distance(completionPoints.at(-1)!, boundaryPoint) > 1e-9) {
327
+ completionPoints.push(boundaryPoint)
328
+ }
329
+ const points = compressPath(completionPoints)
330
+ const segments = getPathSegments(points, traceWidth)
331
+ const clearsObstacles = segments.every((segment) =>
332
+ srj.obstacles.every(
333
+ (obstacle) =>
334
+ obstacle === item.connection.sourceObstacle ||
335
+ !obstacle.layers.includes("top") ||
336
+ distanceSegmentToObstacle(segment, obstacle) >=
337
+ requiredObstacleDistance - 1e-9,
338
+ ),
339
+ )
340
+ if (!clearsObstacles) continue
341
+ const clearsRoutes = segments.every((segment) =>
342
+ acceptedSegments.every(
343
+ (acceptedSegment) =>
344
+ distanceSegmentToSegment(
345
+ segment.start,
346
+ segment.end,
347
+ acceptedSegment.start,
348
+ acceptedSegment.end,
349
+ ) >=
350
+ traceWidth + clearance - 1e-9,
351
+ ),
352
+ )
353
+ if (!clearsRoutes) continue
354
+ return { item, points, track }
355
+ }
356
+ }
357
+ return null
358
+ }
359
+
360
+ function getMaximumObstacleClearDistributionShift(params: {
361
+ route: ActiveRoute
362
+ direction: FanoutDirection
363
+ exitAxis: number
364
+ signedShift: number
365
+ maximumShift: number
366
+ obstacles: Obstacle[]
367
+ requiredObstacleDistance: number
368
+ }): number {
369
+ const {
370
+ route,
371
+ direction,
372
+ exitAxis,
373
+ signedShift,
374
+ maximumShift,
375
+ obstacles,
376
+ requiredObstacleDistance,
377
+ } = params
378
+ if (maximumShift <= 1e-9 || Math.abs(signedShift) <= 1e-9) return 0
379
+ const perpendicularSign = Math.sign(signedShift)
380
+ const shiftIsClear = (shift: number): boolean => {
381
+ if (shift <= 1e-9) return true
382
+ const segment = {
383
+ start: makePoint(
384
+ exitAxis - directionSign(direction) * shift,
385
+ route.track,
386
+ direction,
387
+ ),
388
+ end: makePoint(
389
+ exitAxis,
390
+ route.track + perpendicularSign * shift,
391
+ direction,
392
+ ),
393
+ width: 0,
394
+ layer: "top",
395
+ }
396
+ return obstacles.every(
397
+ (obstacle) =>
398
+ obstacle === route.item.connection.sourceObstacle ||
399
+ !obstacle.layers.includes("top") ||
400
+ distanceSegmentToObstacle(segment, obstacle) >=
401
+ requiredObstacleDistance - 1e-9,
402
+ )
403
+ }
404
+ if (shiftIsClear(maximumShift)) return maximumShift
405
+
406
+ let lowerShift = 0
407
+ let upperShift = maximumShift
408
+ for (let iteration = 0; iteration < 32; iteration++) {
409
+ const candidateShift = (lowerShift + upperShift) / 2
410
+ if (shiftIsClear(candidateShift)) lowerShift = candidateShift
411
+ else upperShift = candidateShift
412
+ }
413
+ return lowerShift
414
+ }
415
+
416
+ /**
417
+ * Finds an ordered subset of candidate tracks with minimum total displacement.
418
+ * Preserving the order is the "shove" invariant: existing routes can move, but
419
+ * they cannot cross when a new pad row joins the channel.
420
+ */
421
+ function selectOrderedTracks(params: {
422
+ requestedTracks: number[]
423
+ currentTracks: number[]
424
+ candidateTracks: number[]
425
+ maximumShifts: number[]
426
+ }): number[] | null {
427
+ const { requestedTracks, currentTracks, candidateTracks, maximumShifts } =
428
+ params
429
+ const rowCount = requestedTracks.length + 1
430
+ const columnCount = candidateTracks.length + 1
431
+ const costs = Array.from({ length: rowCount }, () =>
432
+ new Float64Array(columnCount).fill(Number.POSITIVE_INFINITY),
433
+ )
434
+ const tookCandidate = Array.from(
435
+ { length: rowCount },
436
+ () => new Uint8Array(columnCount),
437
+ )
438
+ costs[0]!.fill(0)
439
+
440
+ for (let row = 1; row < rowCount; row++) {
441
+ for (let column = 1; column < columnCount; column++) {
442
+ const skippedCost = costs[row]![column - 1]!
443
+ const requestedShift = Math.abs(
444
+ requestedTracks[row - 1]! - candidateTracks[column - 1]!,
445
+ )
446
+ const currentShift = Math.abs(
447
+ currentTracks[row - 1]! - candidateTracks[column - 1]!,
448
+ )
449
+ const selectedCost =
450
+ currentShift > maximumShifts[row - 1]! + 1e-9
451
+ ? Number.POSITIVE_INFINITY
452
+ : costs[row - 1]![column - 1]! + requestedShift
453
+ if (selectedCost < skippedCost) {
454
+ costs[row]![column] = selectedCost
455
+ tookCandidate[row]![column] = 1
456
+ } else {
457
+ costs[row]![column] = skippedCost
458
+ }
459
+ }
460
+ }
461
+
462
+ if (!Number.isFinite(costs.at(-1)!.at(-1)!)) return null
463
+ const selectedTracks: number[] = []
464
+ let row = requestedTracks.length
465
+ let column = candidateTracks.length
466
+ while (row > 0 && column > 0) {
467
+ if (tookCandidate[row]![column]) {
468
+ selectedTracks.push(candidateTracks[column - 1]!)
469
+ row--
470
+ }
471
+ column--
472
+ }
473
+ if (row > 0) return null
474
+ return selectedTracks.reverse()
475
+ }
476
+
477
+ function getCandidateTracks(params: {
478
+ direction: FanoutDirection
479
+ activeRoutes: ActiveRoute[]
480
+ obstacles: Obstacle[]
481
+ boundaryMinimum: number
482
+ boundaryMaximum: number
483
+ traceWidth: number
484
+ clearance: number
485
+ requestedTracks: number[]
486
+ maximumShifts: number[]
487
+ }): number[] | null {
488
+ const {
489
+ direction,
490
+ activeRoutes,
491
+ obstacles,
492
+ boundaryMinimum,
493
+ boundaryMaximum,
494
+ traceWidth,
495
+ clearance,
496
+ requestedTracks,
497
+ maximumShifts,
498
+ } = params
499
+ const lanePitch = traceWidth + clearance
500
+ const requiredObstacleDistance = traceWidth / 2 + clearance
501
+ let selectedTracks: number[] | null = null
502
+ let selectedCost = Number.POSITIVE_INFINITY
503
+
504
+ for (const phase of Array.from(
505
+ { length: Math.round(lanePitch / traceWidth) },
506
+ (_, index) => index * traceWidth,
507
+ )) {
508
+ const candidates: number[] = []
509
+ const firstTrack =
510
+ Math.ceil((boundaryMinimum - phase) / lanePitch) * lanePitch + phase
511
+ for (
512
+ let track = firstTrack;
513
+ track <= boundaryMaximum + 1e-9;
514
+ track += lanePitch
515
+ ) {
516
+ const roundedTrack = Math.round(track / traceWidth) * traceWidth
517
+ if (
518
+ obstacles.every((obstacle) => {
519
+ const obstacleTrack = getPerpendicularAxis(obstacle.center, direction)
520
+ const obstacleSize = isHorizontal(direction)
521
+ ? obstacle.height
522
+ : obstacle.width
523
+ return (
524
+ Math.abs(roundedTrack - obstacleTrack) >=
525
+ obstacleSize / 2 + requiredObstacleDistance - 1e-9
526
+ )
527
+ })
528
+ ) {
529
+ candidates.push(roundedTrack)
530
+ }
531
+ }
532
+
533
+ const tracks = selectOrderedTracks({
534
+ requestedTracks,
535
+ currentTracks: activeRoutes.map((route) => route.track),
536
+ candidateTracks: candidates,
537
+ maximumShifts,
538
+ })
539
+ if (!tracks) continue
540
+ const cost = tracks.reduce(
541
+ (sum, track, index) => sum + Math.abs(track - requestedTracks[index]!),
542
+ 0,
543
+ )
544
+ if (cost < selectedCost) {
545
+ selectedCost = cost
546
+ selectedTracks = tracks
547
+ }
548
+ }
549
+ return selectedTracks
550
+ }
551
+
552
+ interface FinalTrackTargets {
553
+ byConnectionName: Map<string, number>
554
+ enforcedConnectionNames: Set<string>
555
+ }
556
+
557
+ function getCornerSide(
558
+ corner: FanoutCorner,
559
+ direction: FanoutDirection,
560
+ ): "minimum" | "maximum" | null {
561
+ switch (direction) {
562
+ case "up":
563
+ if (corner === "top-left") return "minimum"
564
+ if (corner === "top-right") return "maximum"
565
+ return null
566
+ case "down":
567
+ if (corner === "bottom-left") return "minimum"
568
+ if (corner === "bottom-right") return "maximum"
569
+ return null
570
+ case "left":
571
+ if (corner === "bottom-left") return "minimum"
572
+ if (corner === "top-left") return "maximum"
573
+ return null
574
+ case "right":
575
+ if (corner === "bottom-right") return "minimum"
576
+ if (corner === "top-right") return "maximum"
577
+ return null
578
+ }
579
+ }
580
+
581
+ function getFinalTrackTargets(params: {
582
+ items: RoutingItem[]
583
+ direction: FanoutDirection
584
+ boundaryMinimum: number
585
+ boundaryMaximum: number
586
+ traceWidth: number
587
+ clearance: number
588
+ borderDistribution: FanoutBorderDistribution
589
+ currentTrackByConnectionName: ReadonlyMap<string, number>
590
+ }): FinalTrackTargets | null {
591
+ const {
592
+ items,
593
+ direction,
594
+ boundaryMinimum,
595
+ boundaryMaximum,
596
+ traceWidth,
597
+ clearance,
598
+ borderDistribution,
599
+ currentTrackByConnectionName,
600
+ } = params
601
+ const getCurrentTrack = (item: RoutingItem) =>
602
+ currentTrackByConnectionName.get(item.connection.connection.name) ??
603
+ getPerpendicularAxis(item.source, direction)
604
+ const orderedItems = [...items].toSorted(
605
+ (first, second) =>
606
+ getCurrentTrack(first) - getCurrentTrack(second) ||
607
+ first.connection.connection.name.localeCompare(
608
+ second.connection.connection.name,
609
+ ),
610
+ )
611
+ const lanePitch = traceWidth + clearance
612
+ const minimumLane = boundaryMinimum + traceWidth / 2
613
+ const maximumLane = boundaryMaximum - traceWidth / 2
614
+ const availableSpan = maximumLane - minimumLane
615
+ const requiredSpan = lanePitch * Math.max(orderedItems.length - 1, 0)
616
+ if (availableSpan < requiredSpan - 1e-9) return null
617
+
618
+ const sourceTracks = orderedItems.map(getCurrentTrack)
619
+ let targetTracks = [...sourceTracks]
620
+ let distributedPitch = lanePitch
621
+ const enforcedConnectionNames = new Set<string>()
622
+ if (borderDistribution === "even" && orderedItems.length > 1) {
623
+ const sourceMinimum = sourceTracks[0]!
624
+ const sourceMaximum = sourceTracks.at(-1)!
625
+ const desiredPitch = Math.max(
626
+ lanePitch,
627
+ (sourceMaximum - sourceMinimum) / (orderedItems.length - 1),
628
+ )
629
+ const buildOutwardTracks = (pitch: number): number[] => {
630
+ const tracks = [...sourceTracks]
631
+ const upperMiddle = Math.floor(orderedItems.length / 2)
632
+ const lowerMiddle =
633
+ orderedItems.length % 2 === 0 ? upperMiddle - 1 : upperMiddle
634
+ if (lowerMiddle !== upperMiddle) {
635
+ const middleGap = tracks[upperMiddle]! - tracks[lowerMiddle]!
636
+ if (middleGap < pitch) {
637
+ const shove = (pitch - middleGap) / 2
638
+ tracks[lowerMiddle] = tracks[lowerMiddle]! - shove
639
+ tracks[upperMiddle] = tracks[upperMiddle]! + shove
640
+ }
641
+ }
642
+ for (let index = lowerMiddle - 1; index >= 0; index--) {
643
+ tracks[index] = Math.min(tracks[index]!, tracks[index + 1]! - pitch)
644
+ }
645
+ for (let index = upperMiddle + 1; index < tracks.length; index++) {
646
+ tracks[index] = Math.max(tracks[index]!, tracks[index - 1]! + pitch)
647
+ }
648
+ return tracks
649
+ }
650
+ const tracksFitBoundary = (tracks: number[]) =>
651
+ tracks[0]! >= minimumLane - 1e-9 && tracks.at(-1)! <= maximumLane + 1e-9
652
+ targetTracks = buildOutwardTracks(desiredPitch)
653
+ if (tracksFitBoundary(targetTracks)) {
654
+ distributedPitch = desiredPitch
655
+ } else {
656
+ let lowerPitch = lanePitch
657
+ let upperPitch = desiredPitch
658
+ targetTracks = buildOutwardTracks(lowerPitch)
659
+ if (!tracksFitBoundary(targetTracks)) return null
660
+ for (let iteration = 0; iteration < 32; iteration++) {
661
+ const candidatePitch = (lowerPitch + upperPitch) / 2
662
+ const candidateTracks = buildOutwardTracks(candidatePitch)
663
+ if (tracksFitBoundary(candidateTracks)) {
664
+ lowerPitch = candidatePitch
665
+ targetTracks = candidateTracks
666
+ } else {
667
+ upperPitch = candidatePitch
668
+ }
669
+ }
670
+ distributedPitch = lowerPitch
671
+ }
672
+ for (const item of orderedItems) {
673
+ enforcedConnectionNames.add(item.connection.connection.name)
674
+ }
675
+ }
676
+
677
+ const minimumCornerIndexes: number[] = []
678
+ const maximumCornerIndexes: number[] = []
679
+ for (let index = 0; index < orderedItems.length; index++) {
680
+ const item = orderedItems[index]!
681
+ const preferredExit = item.bus.preferredExit
682
+ if (!preferredExit?.includes("-")) continue
683
+ const cornerSide = getCornerSide(preferredExit as FanoutCorner, direction)
684
+ if (!cornerSide) return null
685
+ enforcedConnectionNames.add(item.connection.connection.name)
686
+ if (cornerSide === "minimum") minimumCornerIndexes.push(index)
687
+ else maximumCornerIndexes.push(index)
688
+ }
689
+
690
+ if (
691
+ minimumCornerIndexes.some(
692
+ (index, expectedIndex) => index !== expectedIndex,
693
+ ) ||
694
+ maximumCornerIndexes.some(
695
+ (index, offset) =>
696
+ index !== orderedItems.length - maximumCornerIndexes.length + offset,
697
+ )
698
+ ) {
699
+ return null
700
+ }
701
+ const cornerInset = lanePitch * 2
702
+ if (minimumCornerIndexes.length > 0) {
703
+ targetTracks[0] = minimumLane + cornerInset
704
+ for (let index = 1; index < minimumCornerIndexes.length; index++) {
705
+ const preservedPitch = Math.max(
706
+ distributedPitch,
707
+ sourceTracks[index]! - sourceTracks[index - 1]!,
708
+ )
709
+ targetTracks[index] = targetTracks[index - 1]! + preservedPitch
710
+ }
711
+ }
712
+ if (maximumCornerIndexes.length > 0) {
713
+ const lastIndex = orderedItems.length - 1
714
+ targetTracks[lastIndex] = maximumLane - cornerInset
715
+ for (
716
+ let index = lastIndex - 1;
717
+ index >= orderedItems.length - maximumCornerIndexes.length;
718
+ index--
719
+ ) {
720
+ const preservedPitch = Math.max(
721
+ distributedPitch,
722
+ sourceTracks[index + 1]! - sourceTracks[index]!,
723
+ )
724
+ targetTracks[index] = targetTracks[index + 1]! - preservedPitch
725
+ }
726
+ }
727
+
728
+ return {
729
+ byConnectionName: new Map(
730
+ orderedItems.map((item, index) => [
731
+ item.connection.connection.name,
732
+ targetTracks[index]!,
733
+ ]),
734
+ ),
735
+ enforcedConnectionNames,
736
+ }
737
+ }
738
+
739
+ function buildPlan(path: RoutedPath): FanoutRoutePlan {
740
+ const { item, points, segments } = path
741
+ const route: SimplifiedPcbTrace["route"] = points.map((point, index) => ({
742
+ route_type: "wire",
743
+ x: point.x,
744
+ y: point.y,
745
+ width: segments[0]?.width ?? 0.1,
746
+ layer: "top",
747
+ ...(index === 0 && item.connection.sourcePoint.pcb_port_id
748
+ ? { start_pcb_port_id: item.connection.sourcePoint.pcb_port_id }
749
+ : {}),
750
+ }))
751
+ return {
752
+ busId: item.bus.busId,
753
+ connectionName: item.connection.connection.name,
754
+ connectionIndex: item.connection.connectionIndex,
755
+ sourcePointIndex: item.connection.sourcePointIndex,
756
+ sourcePoint: item.connection.sourcePoint,
757
+ sourceObstacle: item.connection.sourceObstacle,
758
+ sourceLayer: item.connection.sourceLayer,
759
+ targetLayer: "top",
760
+ termination: item.bus.termination,
761
+ direction: item.direction,
762
+ exitPoint: points.at(-1)!,
763
+ trace: {
764
+ type: "pcb_trace",
765
+ pcb_trace_id: `fanout:${item.connection.connection.name}`,
766
+ connection_name: item.connection.connection.name,
767
+ connectsTo: [
768
+ item.connection.connection.name,
769
+ ...(item.connection.sourcePoint.pointId
770
+ ? [item.connection.sourcePoint.pointId]
771
+ : []),
772
+ ...(item.connection.sourcePoint.pcb_port_id
773
+ ? [item.connection.sourcePoint.pcb_port_id]
774
+ : []),
775
+ ],
776
+ route,
777
+ },
778
+ segments,
779
+ length: segments.reduce(
780
+ (total, segment) => total + distance(segment.start, segment.end),
781
+ 0,
782
+ ),
783
+ }
784
+ }
785
+
786
+ function routesAreClear(params: {
787
+ paths: RoutedPath[]
788
+ obstacles: Obstacle[]
789
+ traceWidth: number
790
+ clearance: number
791
+ }): boolean {
792
+ const { paths, obstacles, traceWidth, clearance } = params
793
+ const requiredObstacleDistance = traceWidth / 2 + clearance
794
+ const requiredCenterDistance = traceWidth + clearance
795
+
796
+ for (const path of paths) {
797
+ if (
798
+ Math.abs(
799
+ getAxis(path.points.at(-1)!, path.item.direction) -
800
+ getExitAxis(path.item.bus),
801
+ ) > 1e-6
802
+ ) {
803
+ return false
804
+ }
805
+ for (const segment of path.segments) {
806
+ for (const obstacle of obstacles) {
807
+ if (
808
+ obstacle === path.item.connection.sourceObstacle ||
809
+ !obstacle.layers.includes("top")
810
+ ) {
811
+ continue
812
+ }
813
+ if (
814
+ distanceSegmentToObstacle(segment, obstacle) <
815
+ requiredObstacleDistance - 1e-9
816
+ ) {
817
+ return false
818
+ }
819
+ }
820
+ }
821
+ }
822
+
823
+ for (let firstIndex = 0; firstIndex < paths.length; firstIndex++) {
824
+ for (
825
+ let secondIndex = firstIndex + 1;
826
+ secondIndex < paths.length;
827
+ secondIndex++
828
+ ) {
829
+ for (const firstSegment of paths[firstIndex]!.segments) {
830
+ for (const secondSegment of paths[secondIndex]!.segments) {
831
+ if (
832
+ distanceSegmentToSegment(
833
+ firstSegment.start,
834
+ firstSegment.end,
835
+ secondSegment.start,
836
+ secondSegment.end,
837
+ ) <
838
+ requiredCenterDistance - 1e-9
839
+ ) {
840
+ return false
841
+ }
842
+ }
843
+ }
844
+ }
845
+ }
846
+ return true
847
+ }
848
+
849
+ export function routeSingleLayerWithPushAndShove(
850
+ params: PushShoveParams,
851
+ ): FanoutRoutePlan[] | null {
852
+ const { srj, buses, traceWidth, clearance, borderDistribution } = params
853
+ const requiredObstacleDistance = traceWidth / 2 + clearance
854
+ const recordsByConnectionName = new Map<string, ActiveRoute>()
855
+ const enforcedFinalTargets = new Map<string, number>()
856
+ const items = buses.flatMap((bus) =>
857
+ bus.connections.map((connection) => {
858
+ const source = {
859
+ x: connection.sourcePoint.x,
860
+ y: connection.sourcePoint.y,
861
+ }
862
+ const cornerChannelPrefixes = getCornerChannelPrefixes({
863
+ srj,
864
+ bus,
865
+ connection,
866
+ traceWidth,
867
+ clearance,
868
+ })
869
+ return {
870
+ bus,
871
+ connection,
872
+ direction: bus.direction,
873
+ source,
874
+ ...(cornerChannelPrefixes ? { cornerChannelPrefixes } : {}),
875
+ } satisfies RoutingItem
876
+ }),
877
+ )
878
+
879
+ for (const direction of [
880
+ "left",
881
+ "right",
882
+ "up",
883
+ "down",
884
+ ] as const satisfies readonly FanoutDirection[]) {
885
+ const sign = directionSign(direction)
886
+ const directionItems = items.filter(
887
+ (item) => item.direction === direction && !item.cornerChannelPrefixes,
888
+ )
889
+ if (directionItems.length === 0) continue
890
+ const boundaryMinimum = isHorizontal(direction)
891
+ ? directionItems[0]!.bus.sharedBoundary.minY
892
+ : directionItems[0]!.bus.sharedBoundary.minX
893
+ const boundaryMaximum = isHorizontal(direction)
894
+ ? directionItems[0]!.bus.sharedBoundary.maxY
895
+ : directionItems[0]!.bus.sharedBoundary.maxX
896
+ const exitAxis = getExitAxis(directionItems[0]!.bus)
897
+ const sourcesByEventKey = new Map<string, RoutingItem[]>()
898
+ const obstaclesByEventKey = new Map<string, Obstacle[]>()
899
+ const axisByEventKey = new Map<string, number>()
900
+
901
+ for (const item of directionItems) {
902
+ const axis = getAxis(item.source, direction)
903
+ const key = axis.toFixed(6)
904
+ axisByEventKey.set(key, axis)
905
+ const eventSources = sourcesByEventKey.get(key) ?? []
906
+ eventSources.push(item)
907
+ sourcesByEventKey.set(key, eventSources)
908
+ }
909
+ for (const [eventKey, eventSources] of sourcesByEventKey) {
910
+ const eventAxis = axisByEventKey.get(eventKey)!
911
+ const eventComponentIds = new Set(
912
+ eventSources.map((item) => item.bus.componentId),
913
+ )
914
+ obstaclesByEventKey.set(
915
+ eventKey,
916
+ srj.obstacles.filter(
917
+ (obstacle) =>
918
+ obstacle.layers.includes("top") &&
919
+ !!obstacle.componentId &&
920
+ eventComponentIds.has(obstacle.componentId) &&
921
+ Math.abs(getAxis(obstacle.center, direction) - eventAxis) < 1e-6,
922
+ ),
923
+ )
924
+ }
925
+
926
+ const eventAxes = [...axisByEventKey.values()].toSorted(
927
+ (a, b) => sign * (a - b),
928
+ )
929
+ const active = new Map<string, ActiveRoute>()
930
+ for (let eventIndex = 0; eventIndex < eventAxes.length; eventIndex++) {
931
+ const eventAxis = eventAxes[eventIndex]!
932
+ const eventKey = eventAxis.toFixed(6)
933
+ for (const item of sourcesByEventKey.get(eventKey) ?? []) {
934
+ const directionalPadSize = isHorizontal(direction)
935
+ ? item.connection.sourceObstacle.width
936
+ : item.connection.sourceObstacle.height
937
+ const sourceEscapeDistance =
938
+ Math.max(
939
+ item.connection.sourceObstacle.width,
940
+ item.connection.sourceObstacle.height,
941
+ ) < 1.5
942
+ ? directionalPadSize / 2 + requiredObstacleDistance
943
+ : 0
944
+ const sourceEscapePoint = makePoint(
945
+ eventAxis + sign * sourceEscapeDistance,
946
+ getPerpendicularAxis(item.source, direction),
947
+ direction,
948
+ )
949
+ active.set(item.connection.connection.name, {
950
+ item,
951
+ points:
952
+ distance(item.source, sourceEscapePoint) > 1e-9
953
+ ? [item.source, sourceEscapePoint]
954
+ : [item.source],
955
+ track: getPerpendicularAxis(item.source, direction),
956
+ })
957
+ }
958
+
959
+ const nextAxis = eventAxes[eventIndex + 1] ?? exitAxis
960
+ if (active.size === 0 || sign * (nextAxis - eventAxis) <= 1e-9) {
961
+ continue
962
+ }
963
+ const orderedRoutes = [...active.values()].toSorted(
964
+ (a, b) =>
965
+ a.track - b.track ||
966
+ a.item.connection.connection.name.localeCompare(
967
+ b.item.connection.connection.name,
968
+ ),
969
+ )
970
+ const lookaheadObstacles = eventAxes
971
+ .slice(eventIndex + 1, eventIndex + 3)
972
+ .flatMap((axis) => obstaclesByEventKey.get(axis.toFixed(6)) ?? [])
973
+ const maximumShifts = orderedRoutes.map((route) =>
974
+ Math.abs(nextAxis - getAxis(route.points.at(-1)!, direction)),
975
+ )
976
+ const selectedTracks = getCandidateTracks({
977
+ direction,
978
+ activeRoutes: orderedRoutes,
979
+ obstacles: lookaheadObstacles,
980
+ boundaryMinimum,
981
+ boundaryMaximum,
982
+ traceWidth,
983
+ clearance,
984
+ requestedTracks: orderedRoutes.map((route) => route.track),
985
+ maximumShifts,
986
+ })
987
+ if (!selectedTracks) return null
988
+
989
+ for (let index = 0; index < orderedRoutes.length; index++) {
990
+ const route = orderedRoutes[index]!
991
+ const selectedTrack = selectedTracks[index]!
992
+ const shift = Math.abs(selectedTrack - route.track)
993
+ const routeStartAxis = getAxis(route.points.at(-1)!, direction)
994
+ const diagonalEnd = makePoint(
995
+ routeStartAxis + sign * shift,
996
+ selectedTrack,
997
+ direction,
998
+ )
999
+ if (distance(route.points.at(-1)!, diagonalEnd) > 1e-9) {
1000
+ route.points.push(diagonalEnd)
1001
+ }
1002
+ const nextPoint = makePoint(nextAxis, selectedTrack, direction)
1003
+ if (distance(route.points.at(-1)!, nextPoint) > 1e-9) {
1004
+ route.points.push(nextPoint)
1005
+ }
1006
+ route.track = selectedTrack
1007
+ }
1008
+ }
1009
+
1010
+ const finalTrackTargets = getFinalTrackTargets({
1011
+ items: directionItems,
1012
+ direction,
1013
+ boundaryMinimum,
1014
+ boundaryMaximum,
1015
+ traceWidth,
1016
+ clearance,
1017
+ borderDistribution,
1018
+ currentTrackByConnectionName: new Map(
1019
+ [...active.values()].map((route) => [
1020
+ route.item.connection.connection.name,
1021
+ route.track,
1022
+ ]),
1023
+ ),
1024
+ })
1025
+ if (!finalTrackTargets) return null
1026
+ const orderedActiveRoutes = [...active.values()].toSorted(
1027
+ (first, second) =>
1028
+ first.track - second.track ||
1029
+ first.item.connection.connection.name.localeCompare(
1030
+ second.item.connection.connection.name,
1031
+ ),
1032
+ )
1033
+ const distributionStartOffsetByConnectionName = new Map<string, number>()
1034
+ const lanePitch = traceWidth + clearance
1035
+ let nextMinimumStartOffset = lanePitch
1036
+ for (const route of orderedActiveRoutes) {
1037
+ const connectionName = route.item.connection.connection.name
1038
+ if (!finalTrackTargets.enforcedConnectionNames.has(connectionName)) {
1039
+ continue
1040
+ }
1041
+ const targetTrack =
1042
+ finalTrackTargets.byConnectionName.get(connectionName)!
1043
+ const signedShift = targetTrack - route.track
1044
+ if (signedShift >= -1e-9) continue
1045
+ distributionStartOffsetByConnectionName.set(
1046
+ connectionName,
1047
+ nextMinimumStartOffset,
1048
+ )
1049
+ nextMinimumStartOffset += lanePitch * 2
1050
+ }
1051
+ let nextMaximumStartOffset = lanePitch
1052
+ for (let index = orderedActiveRoutes.length - 1; index >= 0; index--) {
1053
+ const route = orderedActiveRoutes[index]!
1054
+ const connectionName = route.item.connection.connection.name
1055
+ if (!finalTrackTargets.enforcedConnectionNames.has(connectionName)) {
1056
+ continue
1057
+ }
1058
+ const targetTrack =
1059
+ finalTrackTargets.byConnectionName.get(connectionName)!
1060
+ const signedShift = targetTrack - route.track
1061
+ if (signedShift <= 1e-9) continue
1062
+ distributionStartOffsetByConnectionName.set(
1063
+ connectionName,
1064
+ nextMaximumStartOffset,
1065
+ )
1066
+ nextMaximumStartOffset += lanePitch * 2
1067
+ }
1068
+
1069
+ for (const route of orderedActiveRoutes) {
1070
+ const connectionName = route.item.connection.connection.name
1071
+ const intendedTargetTrack =
1072
+ finalTrackTargets.byConnectionName.get(connectionName) ?? route.track
1073
+ const startOffset =
1074
+ distributionStartOffsetByConnectionName.get(connectionName) ?? 0
1075
+ const previousPoint = route.points.at(-2)
1076
+ const availableDepth = previousPoint
1077
+ ? sign * (exitAxis - getAxis(previousPoint, direction))
1078
+ : 0
1079
+ const intendedSignedShift = intendedTargetTrack - route.track
1080
+ const maximumRunwayShift = Math.min(
1081
+ Math.abs(intendedSignedShift),
1082
+ Math.max(0, availableDepth - startOffset),
1083
+ )
1084
+ const maximumShift = getMaximumObstacleClearDistributionShift({
1085
+ route,
1086
+ direction,
1087
+ exitAxis,
1088
+ signedShift: intendedSignedShift,
1089
+ maximumShift: maximumRunwayShift,
1090
+ obstacles: srj.obstacles,
1091
+ requiredObstacleDistance,
1092
+ })
1093
+ const adjustedShift = Math.sign(intendedSignedShift) * maximumShift
1094
+ finalTrackTargets.byConnectionName.set(
1095
+ connectionName,
1096
+ route.track + adjustedShift,
1097
+ )
1098
+ }
1099
+ for (const connectionName of finalTrackTargets.enforcedConnectionNames) {
1100
+ enforcedFinalTargets.set(
1101
+ connectionName,
1102
+ finalTrackTargets.byConnectionName.get(connectionName)!,
1103
+ )
1104
+ }
1105
+
1106
+ for (const route of orderedActiveRoutes) {
1107
+ const connectionName = route.item.connection.connection.name
1108
+ const targetTrack =
1109
+ finalTrackTargets.byConnectionName.get(connectionName) ?? route.track
1110
+ const shift = Math.abs(targetTrack - route.track)
1111
+ const distributionStartOffset =
1112
+ distributionStartOffsetByConnectionName.get(connectionName) ?? 0
1113
+ const distributionDepth =
1114
+ shift > 1e-9 ? distributionStartOffset + shift : 0
1115
+ const distributionStartAxis = exitAxis - sign * distributionDepth
1116
+ const lastPoint = route.points.at(-1)!
1117
+ if (Math.abs(getAxis(lastPoint, direction) - exitAxis) > 1e-6) {
1118
+ return null
1119
+ }
1120
+ const previousPoint = route.points.at(-2)
1121
+ if (
1122
+ previousPoint &&
1123
+ (Math.abs(
1124
+ getPerpendicularAxis(previousPoint, direction) - route.track,
1125
+ ) > 1e-6 ||
1126
+ sign * (distributionStartAxis - getAxis(previousPoint, direction)) <
1127
+ -1e-6)
1128
+ ) {
1129
+ return null
1130
+ }
1131
+ const stagingPoint = makePoint(
1132
+ distributionStartAxis,
1133
+ route.track,
1134
+ direction,
1135
+ )
1136
+ if (previousPoint && distance(previousPoint, stagingPoint) < 1e-9) {
1137
+ route.points.pop()
1138
+ } else {
1139
+ route.points[route.points.length - 1] = stagingPoint
1140
+ }
1141
+ }
1142
+
1143
+ for (const route of orderedActiveRoutes) {
1144
+ const connectionName = route.item.connection.connection.name
1145
+ const targetTrack =
1146
+ finalTrackTargets.byConnectionName.get(connectionName) ?? route.track
1147
+ const shift = Math.abs(targetTrack - route.track)
1148
+ const distributionStartOffset =
1149
+ distributionStartOffsetByConnectionName.get(connectionName) ?? 0
1150
+ if (shift > 1e-9 && distributionStartOffset > 1e-9) {
1151
+ route.points.push(
1152
+ makePoint(
1153
+ getAxis(route.points.at(-1)!, direction) +
1154
+ sign * distributionStartOffset,
1155
+ route.track,
1156
+ direction,
1157
+ ),
1158
+ )
1159
+ }
1160
+ if (shift > 1e-9) {
1161
+ route.points.push(
1162
+ makePoint(
1163
+ getAxis(route.points.at(-1)!, direction) + sign * shift,
1164
+ targetTrack,
1165
+ direction,
1166
+ ),
1167
+ )
1168
+ route.track = targetTrack
1169
+ }
1170
+ const boundaryPoint = makePoint(exitAxis, route.track, direction)
1171
+ if (distance(route.points.at(-1)!, boundaryPoint) > 1e-9) {
1172
+ if (
1173
+ sign * (exitAxis - getAxis(route.points.at(-1)!, direction)) <
1174
+ -1e-6
1175
+ ) {
1176
+ return null
1177
+ }
1178
+ route.points.push(boundaryPoint)
1179
+ }
1180
+ recordsByConnectionName.set(connectionName, route)
1181
+ }
1182
+ }
1183
+
1184
+ const acceptedSegments = [...recordsByConnectionName.values()].flatMap(
1185
+ (route) => getPathSegments(compressPath(route.points), traceWidth),
1186
+ )
1187
+ for (const item of items) {
1188
+ if (!item.cornerChannelPrefixes) continue
1189
+ const route = completeCornerChannelRoute({
1190
+ item,
1191
+ srj,
1192
+ acceptedSegments,
1193
+ traceWidth,
1194
+ clearance,
1195
+ })
1196
+ if (!route) return null
1197
+ recordsByConnectionName.set(item.connection.connection.name, route)
1198
+ acceptedSegments.push(
1199
+ ...getPathSegments(compressPath(route.points), traceWidth),
1200
+ )
1201
+ }
1202
+
1203
+ if (recordsByConnectionName.size !== items.length) return null
1204
+ const maximumTargetError = (traceWidth + clearance) / 2 + 1e-6
1205
+ for (const [connectionName, targetTrack] of enforcedFinalTargets) {
1206
+ const route = recordsByConnectionName.get(connectionName)
1207
+ if (!route || Math.abs(route.track - targetTrack) > maximumTargetError) {
1208
+ return null
1209
+ }
1210
+ }
1211
+ const paths = items.map((item) => {
1212
+ const activeRoute = recordsByConnectionName.get(
1213
+ item.connection.connection.name,
1214
+ )!
1215
+ const points = compressPath(activeRoute.points)
1216
+ return {
1217
+ item,
1218
+ points,
1219
+ segments: getPathSegments(points, traceWidth),
1220
+ } satisfies RoutedPath
1221
+ })
1222
+ if (
1223
+ !routesAreClear({
1224
+ paths,
1225
+ obstacles: srj.obstacles,
1226
+ traceWidth,
1227
+ clearance,
1228
+ })
1229
+ ) {
1230
+ return null
1231
+ }
1232
+ return paths.map(buildPlan)
1233
+ }