@tscircuit/fanout-solver 0.0.35 → 0.0.37

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,898 @@
1
+ import type {
2
+ SimpleRouteJson,
3
+ SimplifiedPcbTrace,
4
+ } from "@tscircuit/capacity-autorouter"
5
+ import { getCornerBandSide, getDirectionForExitEdge } from "./boundary-exit"
6
+ import { createFanoutOutputIds } from "./fanout-output-ids"
7
+ import {
8
+ distance,
9
+ distancePointToSegment,
10
+ distanceSegmentToObstacle,
11
+ distanceSegmentToSegment,
12
+ } from "./geometry"
13
+ import { getAllRoutedTraceCopper } from "./get-routed-trace-copper"
14
+ import { getLayerSpan } from "./layer-names"
15
+ import {
16
+ connectionsShareElectricalNet,
17
+ obstacleSharesElectricalNet,
18
+ } from "./net-identity"
19
+ import type {
20
+ FanoutRoutePlan,
21
+ Point2D,
22
+ PreparedBus,
23
+ PreparedConnection,
24
+ RoutedSegment,
25
+ RoutedVia,
26
+ } from "./types"
27
+
28
+ const EPSILON = 1e-7
29
+ const MAX_GRID_NODE_COUNT = 120_000
30
+ const MAX_EXPANDED_STATE_COUNT = 240_000
31
+ const MAX_CONNECTOR_COUNT = 24
32
+ const CONNECTOR_RADIUS_IN_STEPS = 3.25
33
+
34
+ export interface ViaMinimalWindingTerminal {
35
+ connection: PreparedConnection
36
+ viaPoint: Point2D
37
+ exitPoint: Point2D
38
+ }
39
+
40
+ export interface RouteViaMinimalWindingParams {
41
+ srj: SimpleRouteJson
42
+ bus: PreparedBus
43
+ targetLayer: string
44
+ terminals: ViaMinimalWindingTerminal[]
45
+ acceptedPlans: FanoutRoutePlan[]
46
+ layerNames: string[]
47
+ traceWidth: number
48
+ viaDiameter: number
49
+ viaHoleDiameter: number
50
+ clearance: number
51
+ allowSameNetMerges?: boolean
52
+ }
53
+
54
+ interface GridNode {
55
+ point: Point2D
56
+ column: number
57
+ row: number
58
+ }
59
+
60
+ interface ConnectorCandidate {
61
+ nodeIndex: number
62
+ points: Point2D[]
63
+ radialDistance: number
64
+ length: number
65
+ }
66
+
67
+ interface BlockingSegment {
68
+ connectionName: string
69
+ segment: RoutedSegment
70
+ }
71
+
72
+ interface BlockingVia {
73
+ connectionName: string
74
+ via: Pick<RoutedVia, "center" | "diameter" | "spanLayers">
75
+ }
76
+
77
+ interface HeapEntry {
78
+ node: number
79
+ direction: number
80
+ score: number
81
+ }
82
+
83
+ class MinHeap {
84
+ private values: HeapEntry[] = []
85
+
86
+ get size(): number {
87
+ return this.values.length
88
+ }
89
+
90
+ push(value: HeapEntry): void {
91
+ this.values.push(value)
92
+ let index = this.values.length - 1
93
+ while (index > 0) {
94
+ const parent = Math.floor((index - 1) / 2)
95
+ if (this.values[parent]!.score <= value.score) break
96
+ this.values[index] = this.values[parent]!
97
+ index = parent
98
+ }
99
+ this.values[index] = value
100
+ }
101
+
102
+ pop(): HeapEntry | undefined {
103
+ const result = this.values[0]
104
+ const last = this.values.pop()
105
+ if (!result || !last || this.values.length === 0) return result
106
+ let index = 0
107
+ while (true) {
108
+ const left = index * 2 + 1
109
+ const right = left + 1
110
+ if (left >= this.values.length) break
111
+ const child =
112
+ right < this.values.length &&
113
+ this.values[right]!.score < this.values[left]!.score
114
+ ? right
115
+ : left
116
+ if (this.values[child]!.score >= last.score) break
117
+ this.values[index] = this.values[child]!
118
+ index = child
119
+ }
120
+ this.values[index] = last
121
+ return result
122
+ }
123
+ }
124
+
125
+ function getPerpendicularAxis(
126
+ point: Point2D,
127
+ direction: PreparedBus["direction"],
128
+ ): number {
129
+ return direction === "left" || direction === "right" ? point.y : point.x
130
+ }
131
+
132
+ function getConnectorVariants(start: Point2D, end: Point2D): Point2D[][] {
133
+ const deltaX = end.x - start.x
134
+ const deltaY = end.y - start.y
135
+ const absoluteX = Math.abs(deltaX)
136
+ const absoluteY = Math.abs(deltaY)
137
+ if (
138
+ absoluteX < EPSILON ||
139
+ absoluteY < EPSILON ||
140
+ Math.abs(absoluteX - absoluteY) < EPSILON
141
+ ) {
142
+ return [[start, end]]
143
+ }
144
+ if (absoluteX > absoluteY) {
145
+ return [
146
+ [start, { x: start.x + Math.sign(deltaX) * absoluteY, y: end.y }, end],
147
+ [start, { x: end.x - Math.sign(deltaX) * absoluteY, y: start.y }, end],
148
+ ]
149
+ }
150
+ return [
151
+ [start, { x: end.x, y: start.y + Math.sign(deltaY) * absoluteX }, end],
152
+ [start, { x: start.x, y: end.y - Math.sign(deltaY) * absoluteX }, end],
153
+ ]
154
+ }
155
+
156
+ function compressPath(points: Point2D[]): Point2D[] {
157
+ if (points.length < 3) return points
158
+ const compressed = [points[0]!]
159
+ for (let index = 1; index < points.length - 1; index++) {
160
+ const previous = compressed.at(-1)!
161
+ const current = points[index]!
162
+ const next = points[index + 1]!
163
+ const incomingX = Math.sign(current.x - previous.x)
164
+ const incomingY = Math.sign(current.y - previous.y)
165
+ const outgoingX = Math.sign(next.x - current.x)
166
+ const outgoingY = Math.sign(next.y - current.y)
167
+ if (incomingX !== outgoingX || incomingY !== outgoingY) {
168
+ compressed.push(current)
169
+ }
170
+ }
171
+ compressed.push(points.at(-1)!)
172
+ return compressed
173
+ }
174
+
175
+ function getSegments(
176
+ points: readonly Point2D[],
177
+ width: number,
178
+ layer: string,
179
+ ): RoutedSegment[] {
180
+ return points.slice(1).flatMap((point, index) => {
181
+ const start = points[index]!
182
+ return distance(start, point) < EPSILON
183
+ ? []
184
+ : [{ start, end: point, width, layer }]
185
+ })
186
+ }
187
+
188
+ function segmentIsStraightOr45Degrees(segment: RoutedSegment): boolean {
189
+ const deltaX = Math.abs(segment.end.x - segment.start.x)
190
+ const deltaY = Math.abs(segment.end.y - segment.start.y)
191
+ return (
192
+ deltaX < EPSILON || deltaY < EPSILON || Math.abs(deltaX - deltaY) < EPSILON
193
+ )
194
+ }
195
+
196
+ function pathHasNoProperSelfCrossing(segments: RoutedSegment[]): boolean {
197
+ for (let firstIndex = 0; firstIndex < segments.length; firstIndex++) {
198
+ for (
199
+ let secondIndex = firstIndex + 2;
200
+ secondIndex < segments.length;
201
+ secondIndex++
202
+ ) {
203
+ if (
204
+ firstIndex === 0 &&
205
+ secondIndex === segments.length - 1 &&
206
+ distance(segments[firstIndex]!.start, segments[secondIndex]!.end) <
207
+ EPSILON
208
+ ) {
209
+ continue
210
+ }
211
+ if (
212
+ distanceSegmentToSegment(
213
+ segments[firstIndex]!.start,
214
+ segments[firstIndex]!.end,
215
+ segments[secondIndex]!.start,
216
+ segments[secondIndex]!.end,
217
+ ) < EPSILON
218
+ ) {
219
+ return false
220
+ }
221
+ }
222
+ }
223
+ return true
224
+ }
225
+
226
+ function getPlanVias(plan: FanoutRoutePlan): RoutedVia[] {
227
+ return [
228
+ plan.via,
229
+ ...(plan.additionalVias ?? []),
230
+ plan.planeEndpointVia,
231
+ ].filter((via): via is RoutedVia => Boolean(via))
232
+ }
233
+
234
+ function getBlockingCopper(params: {
235
+ srj: SimpleRouteJson
236
+ acceptedPlans: readonly FanoutRoutePlan[]
237
+ }): { segments: BlockingSegment[]; vias: BlockingVia[] } {
238
+ const { srj, acceptedPlans } = params
239
+ const routedTraceCopper = getAllRoutedTraceCopper(srj)
240
+ return {
241
+ segments: [
242
+ ...routedTraceCopper.flatMap((copper) =>
243
+ copper.segments.map((segment) => ({
244
+ connectionName: copper.connectionName,
245
+ segment,
246
+ })),
247
+ ),
248
+ ...acceptedPlans.flatMap((plan) =>
249
+ [...plan.segments, ...(plan.planeEndpointSegments ?? [])].map(
250
+ (segment) => ({
251
+ connectionName: plan.connectionName,
252
+ segment,
253
+ }),
254
+ ),
255
+ ),
256
+ ],
257
+ vias: [
258
+ ...routedTraceCopper.flatMap((copper) =>
259
+ copper.vias.map((via) => ({
260
+ connectionName: copper.connectionName,
261
+ via,
262
+ })),
263
+ ),
264
+ ...acceptedPlans.flatMap((plan) =>
265
+ getPlanVias(plan).map((via) => ({
266
+ connectionName: plan.connectionName,
267
+ via,
268
+ })),
269
+ ),
270
+ ],
271
+ }
272
+ }
273
+
274
+ function buildPlan(params: {
275
+ bus: PreparedBus
276
+ terminal: ViaMinimalWindingTerminal
277
+ targetLayer: string
278
+ targetLayerPoints: Point2D[]
279
+ layerNames: string[]
280
+ traceWidth: number
281
+ viaDiameter: number
282
+ viaHoleDiameter: number
283
+ }): FanoutRoutePlan {
284
+ const {
285
+ bus,
286
+ terminal,
287
+ targetLayer,
288
+ targetLayerPoints,
289
+ layerNames,
290
+ traceWidth,
291
+ viaDiameter,
292
+ viaHoleDiameter,
293
+ } = params
294
+ const connection = terminal.connection
295
+ const sourcePoint = {
296
+ x: connection.sourcePoint.x,
297
+ y: connection.sourcePoint.y,
298
+ }
299
+ const sourceSegment: RoutedSegment = {
300
+ start: sourcePoint,
301
+ end: terminal.viaPoint,
302
+ width: traceWidth,
303
+ layer: connection.sourceLayer,
304
+ }
305
+ const targetSegments = getSegments(targetLayerPoints, traceWidth, targetLayer)
306
+ const spanLayers = getLayerSpan(
307
+ connection.sourceLayer,
308
+ targetLayer,
309
+ layerNames,
310
+ )
311
+ const via: RoutedVia = {
312
+ center: terminal.viaPoint,
313
+ diameter: viaDiameter,
314
+ holeDiameter: viaHoleDiameter,
315
+ fromLayer: connection.sourceLayer,
316
+ toLayer: targetLayer,
317
+ spanLayers,
318
+ }
319
+ const route: SimplifiedPcbTrace["route"] = [
320
+ {
321
+ route_type: "wire",
322
+ ...sourcePoint,
323
+ width: traceWidth,
324
+ layer: connection.sourceLayer,
325
+ ...(connection.sourcePoint.pcb_port_id
326
+ ? { start_pcb_port_id: connection.sourcePoint.pcb_port_id }
327
+ : {}),
328
+ },
329
+ {
330
+ route_type: "wire",
331
+ ...terminal.viaPoint,
332
+ width: traceWidth,
333
+ layer: connection.sourceLayer,
334
+ },
335
+ {
336
+ route_type: "via",
337
+ ...terminal.viaPoint,
338
+ from_layer: connection.sourceLayer,
339
+ to_layer: targetLayer,
340
+ via_diameter: viaDiameter,
341
+ via_hole_diameter: viaHoleDiameter,
342
+ },
343
+ {
344
+ route_type: "wire",
345
+ ...terminal.viaPoint,
346
+ width: traceWidth,
347
+ layer: targetLayer,
348
+ },
349
+ ...targetLayerPoints.slice(1).map((point) => ({
350
+ route_type: "wire" as const,
351
+ ...point,
352
+ width: traceWidth,
353
+ layer: targetLayer,
354
+ })),
355
+ ]
356
+ const outputIds = createFanoutOutputIds({
357
+ connectionName: connection.connection.name,
358
+ sourcePointIndex: connection.sourcePointIndex,
359
+ })
360
+ const segments = [sourceSegment, ...targetSegments]
361
+ const cornerBandSide = getCornerBandSide(bus.exitEdge, bus.preferredExit)
362
+ return {
363
+ busId: bus.busId,
364
+ connectionName: connection.connection.name,
365
+ connectionIndex: connection.connectionIndex,
366
+ sourcePointIndex: connection.sourcePointIndex,
367
+ sourcePoint: connection.sourcePoint,
368
+ sourceObstacle: connection.sourceObstacle,
369
+ sourceLayer: connection.sourceLayer,
370
+ targetPoint: connection.targetPoint,
371
+ targetLayer,
372
+ termination: bus.termination,
373
+ direction: bus.direction,
374
+ ...(bus.exitEdge ? { exitEdge: bus.exitEdge } : {}),
375
+ ...(cornerBandSide ? { cornerBandSide } : {}),
376
+ exitPoint: terminal.exitPoint,
377
+ trace: {
378
+ type: "pcb_trace",
379
+ pcb_trace_id: outputIds.traceId,
380
+ connection_name: connection.connection.name,
381
+ connectsTo: [
382
+ ...(connection.sourcePoint.pointId
383
+ ? [connection.sourcePoint.pointId]
384
+ : []),
385
+ ...(connection.sourcePoint.pcb_port_id
386
+ ? [connection.sourcePoint.pcb_port_id]
387
+ : []),
388
+ outputIds.boundaryExitPointId,
389
+ ],
390
+ route,
391
+ },
392
+ segments,
393
+ via,
394
+ length: segments.reduce(
395
+ (total, segment) => total + distance(segment.start, segment.end),
396
+ 0,
397
+ ),
398
+ }
399
+ }
400
+
401
+ export function routeViaMinimalWinding(
402
+ params: RouteViaMinimalWindingParams,
403
+ ): FanoutRoutePlan[] | null {
404
+ const {
405
+ srj,
406
+ bus,
407
+ targetLayer,
408
+ terminals,
409
+ acceptedPlans,
410
+ layerNames,
411
+ traceWidth,
412
+ viaDiameter,
413
+ viaHoleDiameter,
414
+ clearance,
415
+ allowSameNetMerges = false,
416
+ } = params
417
+ if (
418
+ terminals.length === 0 ||
419
+ !bus.exitEdge ||
420
+ terminals.some(
421
+ (terminal) => terminal.connection.sourceLayer === targetLayer,
422
+ )
423
+ ) {
424
+ return null
425
+ }
426
+
427
+ const gridStep = traceWidth + clearance
428
+ if (!Number.isFinite(gridStep) || gridStep <= 0) return null
429
+ const { minX, maxX, minY, maxY } = bus.sharedBoundary
430
+ const columnCount = Math.floor((maxX - minX) / gridStep) + 1
431
+ const rowCount = Math.floor((maxY - minY) / gridStep) + 1
432
+ const nodeCount = columnCount * rowCount
433
+ if (columnCount < 2 || rowCount < 2 || nodeCount > MAX_GRID_NODE_COUNT) {
434
+ return null
435
+ }
436
+ const nodes: GridNode[] = Array.from({ length: nodeCount }, (_, index) => {
437
+ const column = index % columnCount
438
+ const row = Math.floor(index / columnCount)
439
+ return {
440
+ column,
441
+ row,
442
+ point: { x: minX + column * gridStep, y: minY + row * gridStep },
443
+ }
444
+ })
445
+ const targetLayerObstacles = srj.obstacles.filter((obstacle) =>
446
+ obstacle.layers.includes(targetLayer),
447
+ )
448
+ const blockingCopper = getBlockingCopper({ srj, acceptedPlans })
449
+ const blockingSegments = blockingCopper.segments.filter(({ segment }) => {
450
+ if (segment.layer !== targetLayer) return false
451
+ const margin = (segment.width + traceWidth) / 2 + clearance
452
+ return !(
453
+ Math.max(segment.start.x, segment.end.x) < minX - margin ||
454
+ Math.min(segment.start.x, segment.end.x) > maxX + margin ||
455
+ Math.max(segment.start.y, segment.end.y) < minY - margin ||
456
+ Math.min(segment.start.y, segment.end.y) > maxY + margin
457
+ )
458
+ })
459
+ const blockingVias = blockingCopper.vias.filter(({ via }) => {
460
+ if (!via.spanLayers.includes(targetLayer)) return false
461
+ const margin = via.diameter / 2 + traceWidth / 2 + clearance
462
+ return !(
463
+ via.center.x < minX - margin ||
464
+ via.center.x > maxX + margin ||
465
+ via.center.y < minY - margin ||
466
+ via.center.y > maxY + margin
467
+ )
468
+ })
469
+ const terminalVias: BlockingVia[] = terminals.map((terminal) => ({
470
+ connectionName: terminal.connection.connection.name,
471
+ via: {
472
+ center: terminal.viaPoint,
473
+ diameter: viaDiameter,
474
+ spanLayers: getLayerSpan(
475
+ terminal.connection.sourceLayer,
476
+ targetLayer,
477
+ layerNames,
478
+ ),
479
+ },
480
+ }))
481
+ const boundaryDirection = getDirectionForExitEdge(bus.exitEdge)
482
+ const sharesNet = (first: string, second: string): boolean =>
483
+ first === second ||
484
+ (allowSameNetMerges && connectionsShareElectricalNet(srj, first, second))
485
+
486
+ const segmentIsClear = (params: {
487
+ segment: RoutedSegment
488
+ terminal: ViaMinimalWindingTerminal
489
+ acceptedAttemptSegments: BlockingSegment[]
490
+ }): boolean => {
491
+ const { segment, terminal, acceptedAttemptSegments } = params
492
+ const connectionName = terminal.connection.connection.name
493
+ for (const obstacle of targetLayerObstacles) {
494
+ if (
495
+ obstacle.connectedTo.includes(connectionName) ||
496
+ (allowSameNetMerges &&
497
+ obstacleSharesElectricalNet(srj, obstacle, connectionName))
498
+ ) {
499
+ continue
500
+ }
501
+ if (
502
+ distanceSegmentToObstacle(segment, obstacle) <
503
+ segment.width / 2 + clearance - EPSILON
504
+ ) {
505
+ return false
506
+ }
507
+ }
508
+ for (const blocker of blockingSegments) {
509
+ if (sharesNet(connectionName, blocker.connectionName)) continue
510
+ if (
511
+ distanceSegmentToSegment(
512
+ segment.start,
513
+ segment.end,
514
+ blocker.segment.start,
515
+ blocker.segment.end,
516
+ ) <
517
+ (segment.width + blocker.segment.width) / 2 + clearance - EPSILON
518
+ ) {
519
+ return false
520
+ }
521
+ }
522
+ for (const blocker of acceptedAttemptSegments) {
523
+ if (sharesNet(connectionName, blocker.connectionName)) continue
524
+ if (
525
+ distanceSegmentToSegment(
526
+ segment.start,
527
+ segment.end,
528
+ blocker.segment.start,
529
+ blocker.segment.end,
530
+ ) <
531
+ (segment.width + blocker.segment.width) / 2 + clearance - EPSILON
532
+ ) {
533
+ return false
534
+ }
535
+ }
536
+ for (const blocker of blockingVias) {
537
+ if (sharesNet(connectionName, blocker.connectionName)) continue
538
+ if (
539
+ distancePointToSegment(blocker.via.center, segment.start, segment.end) <
540
+ blocker.via.diameter / 2 + segment.width / 2 + clearance - EPSILON
541
+ ) {
542
+ return false
543
+ }
544
+ }
545
+ for (const blocker of terminalVias) {
546
+ if (sharesNet(connectionName, blocker.connectionName)) continue
547
+ if (
548
+ distancePointToSegment(blocker.via.center, segment.start, segment.end) <
549
+ blocker.via.diameter / 2 + segment.width / 2 + clearance - EPSILON
550
+ ) {
551
+ return false
552
+ }
553
+ }
554
+ return true
555
+ }
556
+
557
+ const connectorCandidates = (params: {
558
+ terminal: ViaMinimalWindingTerminal
559
+ endpoint: Point2D
560
+ acceptedAttemptSegments: BlockingSegment[]
561
+ }): ConnectorCandidate[] => {
562
+ const { terminal, endpoint, acceptedAttemptSegments } = params
563
+ const candidates: ConnectorCandidate[] = []
564
+ for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++) {
565
+ const node = nodes[nodeIndex]!
566
+ const connectorDistance = distance(endpoint, node.point)
567
+ if (connectorDistance > gridStep * CONNECTOR_RADIUS_IN_STEPS) continue
568
+ for (const points of getConnectorVariants(endpoint, node.point)) {
569
+ const segments = getSegments(points, traceWidth, targetLayer)
570
+ if (
571
+ !segments.every((segment) =>
572
+ segmentIsClear({
573
+ segment,
574
+ terminal,
575
+ acceptedAttemptSegments,
576
+ }),
577
+ )
578
+ ) {
579
+ continue
580
+ }
581
+ candidates.push({
582
+ nodeIndex,
583
+ points,
584
+ radialDistance: connectorDistance,
585
+ length: getSegments(points, traceWidth, targetLayer).reduce(
586
+ (total, segment) => total + distance(segment.start, segment.end),
587
+ 0,
588
+ ),
589
+ })
590
+ }
591
+ }
592
+ return candidates
593
+ .toSorted(
594
+ (first, second) =>
595
+ first.radialDistance - second.radialDistance ||
596
+ first.length - second.length ||
597
+ first.nodeIndex - second.nodeIndex,
598
+ )
599
+ .slice(0, MAX_CONNECTOR_COUNT)
600
+ }
601
+
602
+ const routeOne = (params: {
603
+ terminal: ViaMinimalWindingTerminal
604
+ acceptedAttemptSegments: BlockingSegment[]
605
+ laneBias: -1 | 0 | 1
606
+ }): Point2D[] | null => {
607
+ const { terminal, acceptedAttemptSegments, laneBias } = params
608
+ const starts = connectorCandidates({
609
+ terminal,
610
+ endpoint: terminal.viaPoint,
611
+ acceptedAttemptSegments,
612
+ })
613
+ const ends = connectorCandidates({
614
+ terminal,
615
+ endpoint: terminal.exitPoint,
616
+ acceptedAttemptSegments,
617
+ })
618
+ if (starts.length === 0 || ends.length === 0) return null
619
+ const endByNode = new Map<number, ConnectorCandidate[]>()
620
+ for (const end of ends) {
621
+ const values = endByNode.get(end.nodeIndex) ?? []
622
+ values.push(end)
623
+ endByNode.set(end.nodeIndex, values)
624
+ }
625
+ const stateCount = nodeCount * 9
626
+ const distances = new Float64Array(stateCount).fill(
627
+ Number.POSITIVE_INFINITY,
628
+ )
629
+ const previous = new Int32Array(stateCount).fill(-1)
630
+ const heap = new MinHeap()
631
+ const heuristic = (point: Point2D): number => {
632
+ const deltaX = Math.abs(point.x - terminal.exitPoint.x)
633
+ const deltaY = Math.abs(point.y - terminal.exitPoint.y)
634
+ return (
635
+ Math.max(deltaX, deltaY) + (Math.SQRT2 - 1) * Math.min(deltaX, deltaY)
636
+ )
637
+ }
638
+ for (const start of starts) {
639
+ const state = start.nodeIndex * 9 + 8
640
+ if (start.length >= distances[state]!) continue
641
+ distances[state] = start.length
642
+ const remaining = heuristic(nodes[start.nodeIndex]!.point)
643
+ heap.push({
644
+ node: start.nodeIndex,
645
+ direction: 8,
646
+ score: start.length + remaining,
647
+ })
648
+ }
649
+ const directions = [
650
+ [1, 0],
651
+ [1, 1],
652
+ [0, 1],
653
+ [-1, 1],
654
+ [-1, 0],
655
+ [-1, -1],
656
+ [0, -1],
657
+ [1, -1],
658
+ ] as const
659
+ const startsByNode = new Map<number, ConnectorCandidate[]>()
660
+ for (const start of starts) {
661
+ const values = startsByNode.get(start.nodeIndex) ?? []
662
+ values.push(start)
663
+ startsByNode.set(start.nodeIndex, values)
664
+ }
665
+ let bestGoalCost = Number.POSITIVE_INFINITY
666
+ let bestGoalPoints: Point2D[] | null = null
667
+ let expandedStateCount = 0
668
+ while (heap.size > 0 && expandedStateCount < MAX_EXPANDED_STATE_COUNT) {
669
+ const current = heap.pop()!
670
+ if (current.score >= bestGoalCost - EPSILON) break
671
+ const state = current.node * 9 + current.direction
672
+ const currentDistance = distances[state]!
673
+ if (
674
+ current.score >
675
+ currentDistance + heuristic(nodes[current.node]!.point) + EPSILON
676
+ )
677
+ continue
678
+ expandedStateCount++
679
+ const endConnectors = endByNode.get(current.node)
680
+ if (endConnectors) {
681
+ const gridPoints: Point2D[] = []
682
+ let pathState = state
683
+ while (pathState >= 0) {
684
+ gridPoints.push(nodes[Math.floor(pathState / 9)]!.point)
685
+ pathState = previous[pathState]!
686
+ }
687
+ gridPoints.reverse()
688
+ let firstState = state
689
+ while (previous[firstState]! >= 0) {
690
+ firstState = previous[firstState]!
691
+ }
692
+ const startNodeIndex = Math.floor(firstState / 9)
693
+ const startConnectors = startsByNode.get(startNodeIndex) ?? []
694
+ const shortestStartLength = Math.min(
695
+ ...startConnectors.map((candidate) => candidate.length),
696
+ )
697
+ for (const startConnector of startConnectors) {
698
+ for (const endConnector of endConnectors) {
699
+ const candidateCost =
700
+ currentDistance -
701
+ shortestStartLength +
702
+ startConnector.length +
703
+ endConnector.length
704
+ if (candidateCost >= bestGoalCost - EPSILON) continue
705
+ const points = compressPath([
706
+ ...startConnector.points,
707
+ ...gridPoints.slice(1),
708
+ ...endConnector.points.toReversed().slice(1),
709
+ ])
710
+ const segments = getSegments(points, traceWidth, targetLayer)
711
+ if (
712
+ !segments.every(segmentIsStraightOr45Degrees) ||
713
+ !pathHasNoProperSelfCrossing(segments) ||
714
+ !segments.every((segment) =>
715
+ segmentIsClear({
716
+ segment,
717
+ terminal,
718
+ acceptedAttemptSegments,
719
+ }),
720
+ )
721
+ ) {
722
+ continue
723
+ }
724
+ bestGoalCost = candidateCost
725
+ bestGoalPoints = points
726
+ }
727
+ }
728
+ }
729
+ const node = nodes[current.node]!
730
+ for (
731
+ let directionIndex = 0;
732
+ directionIndex < directions.length;
733
+ directionIndex++
734
+ ) {
735
+ if (current.direction !== 8) {
736
+ const rawDirectionDelta = Math.abs(current.direction - directionIndex)
737
+ if (Math.min(rawDirectionDelta, 8 - rawDirectionDelta) > 1) {
738
+ continue
739
+ }
740
+ }
741
+ const [deltaColumn, deltaRow] = directions[directionIndex]!
742
+ const column = node.column + deltaColumn
743
+ const row = node.row + deltaRow
744
+ if (column < 0 || column >= columnCount || row < 0 || row >= rowCount) {
745
+ continue
746
+ }
747
+ const nextNode = row * columnCount + column
748
+ const nextPoint = nodes[nextNode]!.point
749
+ const segment: RoutedSegment = {
750
+ start: node.point,
751
+ end: nextPoint,
752
+ width: traceWidth,
753
+ layer: targetLayer,
754
+ }
755
+ if (
756
+ !segmentIsClear({
757
+ segment,
758
+ terminal,
759
+ acceptedAttemptSegments,
760
+ })
761
+ ) {
762
+ continue
763
+ }
764
+ const addsTurn =
765
+ current.direction !== 8 && current.direction !== directionIndex
766
+ const nextTrack = getPerpendicularAxis(nextPoint, boundaryDirection)
767
+ const targetTrack = getPerpendicularAxis(
768
+ terminal.exitPoint,
769
+ boundaryDirection,
770
+ )
771
+ const lanePenalty =
772
+ laneBias === 0
773
+ ? 0
774
+ : laneBias > 0
775
+ ? Math.max(0, targetTrack - nextTrack) * 0.2
776
+ : Math.max(0, nextTrack - targetTrack) * 0.2
777
+ const nextDistance =
778
+ currentDistance +
779
+ (deltaColumn !== 0 && deltaRow !== 0
780
+ ? gridStep * Math.SQRT2
781
+ : gridStep) +
782
+ (addsTurn ? gridStep * 0.2 : 0) +
783
+ lanePenalty
784
+ const nextState = nextNode * 9 + directionIndex
785
+ if (nextDistance >= distances[nextState]! - EPSILON) continue
786
+ distances[nextState] = nextDistance
787
+ previous[nextState] = state
788
+ const remaining = heuristic(nextPoint)
789
+ heap.push({
790
+ node: nextNode,
791
+ direction: directionIndex,
792
+ score: nextDistance + remaining,
793
+ })
794
+ }
795
+ }
796
+ return bestGoalPoints
797
+ }
798
+
799
+ const targetOrderedTerminals = terminals.toSorted((first, second) => {
800
+ const axisDifference =
801
+ getPerpendicularAxis(first.exitPoint, boundaryDirection) -
802
+ getPerpendicularAxis(second.exitPoint, boundaryDirection)
803
+ return (
804
+ axisDifference ||
805
+ first.connection.connection.name.localeCompare(
806
+ second.connection.connection.name,
807
+ )
808
+ )
809
+ })
810
+ const routeOrderCandidates: ViaMinimalWindingTerminal[][] = [
811
+ targetOrderedTerminals,
812
+ [...targetOrderedTerminals].reverse(),
813
+ terminals.toSorted(
814
+ (first, second) =>
815
+ first.viaPoint.x - second.viaPoint.x ||
816
+ first.viaPoint.y - second.viaPoint.y,
817
+ ),
818
+ terminals.toSorted(
819
+ (first, second) =>
820
+ second.viaPoint.x - first.viaPoint.x ||
821
+ second.viaPoint.y - first.viaPoint.y,
822
+ ),
823
+ terminals.toSorted(
824
+ (first, second) =>
825
+ first.viaPoint.y - second.viaPoint.y ||
826
+ first.viaPoint.x - second.viaPoint.x,
827
+ ),
828
+ terminals.toSorted(
829
+ (first, second) =>
830
+ second.viaPoint.y - first.viaPoint.y ||
831
+ second.viaPoint.x - first.viaPoint.x,
832
+ ),
833
+ ]
834
+ for (let offset = 1; offset < targetOrderedTerminals.length; offset++) {
835
+ routeOrderCandidates.push([
836
+ ...targetOrderedTerminals.slice(offset),
837
+ ...targetOrderedTerminals.slice(0, offset),
838
+ ])
839
+ }
840
+ const seenRouteOrders = new Set<string>()
841
+ const routeOrders = routeOrderCandidates.filter((routeOrder) => {
842
+ const key = routeOrder
843
+ .map((terminal) => terminal.connection.connection.name)
844
+ .join("\u0000")
845
+ if (seenRouteOrders.has(key)) return false
846
+ seenRouteOrders.add(key)
847
+ return true
848
+ })
849
+
850
+ for (const routeOrder of routeOrders) {
851
+ for (const laneBias of [0, 1, -1] as const) {
852
+ const acceptedAttemptSegments: BlockingSegment[] = []
853
+ const routedPointsByConnectionName = new Map<string, Point2D[]>()
854
+ let failed = false
855
+ for (const terminal of routeOrder) {
856
+ const points = routeOne({
857
+ terminal,
858
+ acceptedAttemptSegments,
859
+ laneBias,
860
+ })
861
+ if (!points) {
862
+ failed = true
863
+ break
864
+ }
865
+ const connectionName = terminal.connection.connection.name
866
+ routedPointsByConnectionName.set(connectionName, points)
867
+ acceptedAttemptSegments.push(
868
+ ...getSegments(points, traceWidth, targetLayer).map((segment) => ({
869
+ connectionName,
870
+ segment,
871
+ })),
872
+ )
873
+ }
874
+ if (failed) continue
875
+ return terminals.map((terminal) => {
876
+ const targetLayerPoints = routedPointsByConnectionName.get(
877
+ terminal.connection.connection.name,
878
+ )
879
+ if (!targetLayerPoints) {
880
+ throw new Error(
881
+ `FanoutSolver: via-minimal winding route omitted "${terminal.connection.connection.name}"`,
882
+ )
883
+ }
884
+ return buildPlan({
885
+ bus,
886
+ terminal,
887
+ targetLayer,
888
+ targetLayerPoints,
889
+ layerNames,
890
+ traceWidth,
891
+ viaDiameter,
892
+ viaHoleDiameter,
893
+ })
894
+ })
895
+ }
896
+ }
897
+ return null
898
+ }