@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,1179 @@
1
+ import type {
2
+ Obstacle,
3
+ SimpleRouteJson,
4
+ SimplifiedPcbTrace,
5
+ } from "@tscircuit/capacity-autorouter"
6
+ import {
7
+ distance,
8
+ distancePointToObstacle,
9
+ distanceSegmentToObstacle,
10
+ distanceSegmentToSegment,
11
+ } from "./geometry"
12
+ import type {
13
+ FanoutDirection,
14
+ FanoutRoutePlan,
15
+ Point2D,
16
+ PreparedBus,
17
+ PreparedConnection,
18
+ RoutedSegment,
19
+ } from "./types"
20
+
21
+ interface FlowRoutingParams {
22
+ srj: SimpleRouteJson
23
+ buses: PreparedBus[]
24
+ traceWidth: number
25
+ clearance: number
26
+ }
27
+
28
+ interface FlowItem {
29
+ bus: PreparedBus
30
+ connection: PreparedConnection
31
+ source: Point2D
32
+ netKey: string
33
+ }
34
+
35
+ interface FlowTerminal {
36
+ item: FlowItem
37
+ equivalentItems: FlowItem[]
38
+ candidates: Array<{ node: number; connectorPoints: Point2D[] }>
39
+ }
40
+
41
+ interface FlowRoute {
42
+ item: FlowItem
43
+ points: Point2D[]
44
+ segments: RoutedSegment[]
45
+ }
46
+
47
+ interface DirectionGroupResult {
48
+ routes: FlowRoute[]
49
+ usedNodes: number[]
50
+ unmatchedItems: FlowItem[]
51
+ }
52
+
53
+ interface FlowGrid {
54
+ boundary: PreparedBus["sharedBoundary"]
55
+ step: number
56
+ columnCount: number
57
+ rowCount: number
58
+ nodeCount: number
59
+ points: Point2D[]
60
+ obstacleFreeNodes: Uint8Array
61
+ neighbors: number[][]
62
+ }
63
+
64
+ interface FlowEdge {
65
+ to: number
66
+ reverseIndex: number
67
+ capacity: number
68
+ initialCapacity: number
69
+ gridNode?: number
70
+ isSink?: boolean
71
+ }
72
+
73
+ const EPSILON = 1e-9
74
+ const OBSTACLE_BIN_SIZE = 1
75
+ const FANOUT_FLOW_DEBUG_ENABLED =
76
+ (
77
+ globalThis as typeof globalThis & {
78
+ process?: {
79
+ env?: {
80
+ FANOUT_FLOW_DEBUG?: string
81
+ }
82
+ }
83
+ }
84
+ ).process?.env?.FANOUT_FLOW_DEBUG === "1"
85
+
86
+ function getNetKey(connection: PreparedConnection): string {
87
+ const simpleRouteConnection =
88
+ connection.connection as typeof connection.connection & {
89
+ netConnectionName?: string
90
+ }
91
+ const connectivityNet = connection.sourceObstacle.connectedTo.find(
92
+ (connectionName) => connectionName.startsWith("connectivity_net"),
93
+ )
94
+ return (
95
+ connectivityNet ??
96
+ simpleRouteConnection.netConnectionName ??
97
+ connection.connection.name.replace(/::fanout:\d+$/, "")
98
+ )
99
+ }
100
+
101
+ function obstacleBelongsToItem(obstacle: Obstacle, item: FlowItem): boolean {
102
+ return (
103
+ obstacle === item.connection.sourceObstacle ||
104
+ obstacle.connectedTo.includes(item.connection.connection.name) ||
105
+ obstacle.connectedTo.includes(item.netKey)
106
+ )
107
+ }
108
+
109
+ function connectWith45DegreeSegments(start: Point2D, end: Point2D): Point2D[] {
110
+ const deltaX = end.x - start.x
111
+ const deltaY = end.y - start.y
112
+ const absoluteX = Math.abs(deltaX)
113
+ const absoluteY = Math.abs(deltaY)
114
+ if (
115
+ absoluteX < EPSILON ||
116
+ absoluteY < EPSILON ||
117
+ Math.abs(absoluteX - absoluteY) < EPSILON
118
+ ) {
119
+ return [start, end]
120
+ }
121
+ if (absoluteX > absoluteY) {
122
+ return [
123
+ start,
124
+ {
125
+ x: start.x + Math.sign(deltaX) * absoluteY,
126
+ y: end.y,
127
+ },
128
+ end,
129
+ ]
130
+ }
131
+ return [
132
+ start,
133
+ {
134
+ x: end.x,
135
+ y: start.y + Math.sign(deltaY) * absoluteX,
136
+ },
137
+ end,
138
+ ]
139
+ }
140
+
141
+ function enforceStraightOr45DegreeSegments(points: Point2D[]): Point2D[] {
142
+ if (points.length < 2) return points
143
+ const normalized = [points[0]!]
144
+ for (const end of points.slice(1)) {
145
+ const start = normalized.at(-1)!
146
+ const deltaX = Math.abs(end.x - start.x)
147
+ const deltaY = Math.abs(end.y - start.y)
148
+ if (
149
+ deltaX < EPSILON ||
150
+ deltaY < EPSILON ||
151
+ Math.abs(deltaX - deltaY) < EPSILON
152
+ ) {
153
+ normalized.push(end)
154
+ continue
155
+ }
156
+ normalized.push(
157
+ ...connectWith45DegreeSegments(end, start).reverse().slice(1),
158
+ )
159
+ }
160
+ return compressPath(normalized)
161
+ }
162
+
163
+ function compressPath(points: Point2D[]): Point2D[] {
164
+ if (points.length < 3) return points
165
+ const compressed = [points[0]!]
166
+ for (let index = 1; index < points.length - 1; index++) {
167
+ const previous = compressed.at(-1)!
168
+ const current = points[index]!
169
+ const next = points[index + 1]!
170
+ if (
171
+ Math.sign(current.x - previous.x) !== Math.sign(next.x - current.x) ||
172
+ Math.sign(current.y - previous.y) !== Math.sign(next.y - current.y)
173
+ ) {
174
+ compressed.push(current)
175
+ }
176
+ }
177
+ compressed.push(points.at(-1)!)
178
+ return compressed
179
+ }
180
+
181
+ function chamferOrthogonalPolyline(
182
+ points: Point2D[],
183
+ requestedChamfer: number,
184
+ ): Point2D[] {
185
+ if (points.length < 3) return points
186
+ const chamfered = [points[0]!]
187
+ for (let index = 1; index < points.length - 1; index++) {
188
+ const previous = points[index - 1]!
189
+ const corner = points[index]!
190
+ const next = points[index + 1]!
191
+ const incomingLength = distance(previous, corner)
192
+ const outgoingLength = distance(corner, next)
193
+ if (incomingLength < EPSILON || outgoingLength < EPSILON) continue
194
+ const incoming = {
195
+ x: (corner.x - previous.x) / incomingLength,
196
+ y: (corner.y - previous.y) / incomingLength,
197
+ }
198
+ const outgoing = {
199
+ x: (next.x - corner.x) / outgoingLength,
200
+ y: (next.y - corner.y) / outgoingLength,
201
+ }
202
+ if (Math.abs(incoming.x * outgoing.x + incoming.y * outgoing.y) > 1e-6) {
203
+ chamfered.push(corner)
204
+ continue
205
+ }
206
+ const chamfer = Math.min(
207
+ requestedChamfer,
208
+ incomingLength / 2,
209
+ outgoingLength / 2,
210
+ )
211
+ chamfered.push({
212
+ x: corner.x - incoming.x * chamfer,
213
+ y: corner.y - incoming.y * chamfer,
214
+ })
215
+ chamfered.push({
216
+ x: corner.x + outgoing.x * chamfer,
217
+ y: corner.y + outgoing.y * chamfer,
218
+ })
219
+ }
220
+ chamfered.push(points.at(-1)!)
221
+ return compressPath(chamfered)
222
+ }
223
+
224
+ function getSegments(points: Point2D[], traceWidth: number): RoutedSegment[] {
225
+ const segments: RoutedSegment[] = []
226
+ for (let index = 1; index < points.length; index++) {
227
+ if (distance(points[index - 1]!, points[index]!) < EPSILON) continue
228
+ segments.push({
229
+ start: points[index - 1]!,
230
+ end: points[index]!,
231
+ width: traceWidth,
232
+ layer: "top",
233
+ })
234
+ }
235
+ return segments
236
+ }
237
+
238
+ class Dinic {
239
+ readonly edges: FlowEdge[][]
240
+ private readonly levels: Int32Array
241
+ private readonly nextEdges: Int32Array
242
+
243
+ constructor(nodeCount: number) {
244
+ this.edges = Array.from({ length: nodeCount }, () => [])
245
+ this.levels = new Int32Array(nodeCount)
246
+ this.nextEdges = new Int32Array(nodeCount)
247
+ }
248
+
249
+ addEdge(
250
+ from: number,
251
+ to: number,
252
+ capacity: number,
253
+ metadata: Pick<FlowEdge, "gridNode" | "isSink"> = {},
254
+ ): void {
255
+ const forward: FlowEdge = {
256
+ to,
257
+ reverseIndex: this.edges[to]!.length,
258
+ capacity,
259
+ initialCapacity: capacity,
260
+ ...metadata,
261
+ }
262
+ const reverse: FlowEdge = {
263
+ to: from,
264
+ reverseIndex: this.edges[from]!.length,
265
+ capacity: 0,
266
+ initialCapacity: 0,
267
+ }
268
+ this.edges[from]!.push(forward)
269
+ this.edges[to]!.push(reverse)
270
+ }
271
+
272
+ private buildLevels(source: number, sink: number): boolean {
273
+ this.levels.fill(-1)
274
+ const queue = new Int32Array(this.edges.length)
275
+ let head = 0
276
+ let tail = 0
277
+ queue[tail++] = source
278
+ this.levels[source] = 0
279
+ while (head < tail) {
280
+ const node = queue[head++]!
281
+ for (const edge of this.edges[node]!) {
282
+ if (edge.capacity <= 0 || this.levels[edge.to] >= 0) continue
283
+ this.levels[edge.to] = this.levels[node]! + 1
284
+ queue[tail++] = edge.to
285
+ }
286
+ }
287
+ return this.levels[sink] >= 0
288
+ }
289
+
290
+ private sendFlow(node: number, sink: number): number {
291
+ if (node === sink) return 1
292
+ for (
293
+ let edgeIndex = this.nextEdges[node]!;
294
+ edgeIndex < this.edges[node]!.length;
295
+ edgeIndex++, this.nextEdges[node] = edgeIndex
296
+ ) {
297
+ const edge = this.edges[node]![edgeIndex]!
298
+ if (
299
+ edge.capacity <= 0 ||
300
+ this.levels[edge.to] !== this.levels[node]! + 1
301
+ ) {
302
+ continue
303
+ }
304
+ const sent = this.sendFlow(edge.to, sink)
305
+ if (sent === 0) continue
306
+ edge.capacity -= sent
307
+ this.edges[edge.to]![edge.reverseIndex]!.capacity += sent
308
+ return sent
309
+ }
310
+ return 0
311
+ }
312
+
313
+ maximumFlow(source: number, sink: number, limit: number): number {
314
+ let flow = 0
315
+ while (flow < limit && this.buildLevels(source, sink)) {
316
+ this.nextEdges.fill(0)
317
+ while (flow < limit) {
318
+ const sent = this.sendFlow(source, sink)
319
+ if (sent === 0) break
320
+ flow += sent
321
+ }
322
+ }
323
+ return flow
324
+ }
325
+ }
326
+
327
+ function createFlowGrid(params: {
328
+ boundary: PreparedBus["sharedBoundary"]
329
+ obstacles: Obstacle[]
330
+ traceWidth: number
331
+ clearance: number
332
+ }): FlowGrid {
333
+ const { boundary, obstacles, traceWidth, clearance } = params
334
+ const step = traceWidth + clearance
335
+ const columnCount = Math.round((boundary.maxX - boundary.minX) / step) + 1
336
+ const rowCount = Math.round((boundary.maxY - boundary.minY) / step) + 1
337
+ const nodeCount = columnCount * rowCount
338
+ const points = Array.from({ length: nodeCount }, (_, node) => ({
339
+ x: boundary.minX + (node % columnCount) * step,
340
+ y: boundary.minY + Math.floor(node / columnCount) * step,
341
+ }))
342
+ const requiredObstacleDistance = traceWidth / 2 + clearance
343
+ const obstacleIndexesByBin = new Map<string, number[]>()
344
+ for (
345
+ let obstacleIndex = 0;
346
+ obstacleIndex < obstacles.length;
347
+ obstacleIndex++
348
+ ) {
349
+ const obstacle = obstacles[obstacleIndex]!
350
+ const minBinX = Math.floor(
351
+ (obstacle.center.x - obstacle.width / 2 - requiredObstacleDistance) /
352
+ OBSTACLE_BIN_SIZE,
353
+ )
354
+ const maxBinX = Math.floor(
355
+ (obstacle.center.x + obstacle.width / 2 + requiredObstacleDistance) /
356
+ OBSTACLE_BIN_SIZE,
357
+ )
358
+ const minBinY = Math.floor(
359
+ (obstacle.center.y - obstacle.height / 2 - requiredObstacleDistance) /
360
+ OBSTACLE_BIN_SIZE,
361
+ )
362
+ const maxBinY = Math.floor(
363
+ (obstacle.center.y + obstacle.height / 2 + requiredObstacleDistance) /
364
+ OBSTACLE_BIN_SIZE,
365
+ )
366
+ for (let binX = minBinX; binX <= maxBinX; binX++) {
367
+ for (let binY = minBinY; binY <= maxBinY; binY++) {
368
+ const key = `${binX}:${binY}`
369
+ const indexes = obstacleIndexesByBin.get(key) ?? []
370
+ indexes.push(obstacleIndex)
371
+ obstacleIndexesByBin.set(key, indexes)
372
+ }
373
+ }
374
+ }
375
+ const getNearbyObstacles = (first: Point2D, second = first): Obstacle[] => {
376
+ const minBinX = Math.floor(Math.min(first.x, second.x) / OBSTACLE_BIN_SIZE)
377
+ const maxBinX = Math.floor(Math.max(first.x, second.x) / OBSTACLE_BIN_SIZE)
378
+ const minBinY = Math.floor(Math.min(first.y, second.y) / OBSTACLE_BIN_SIZE)
379
+ const maxBinY = Math.floor(Math.max(first.y, second.y) / OBSTACLE_BIN_SIZE)
380
+ const indexes = new Set<number>()
381
+ for (let binX = minBinX; binX <= maxBinX; binX++) {
382
+ for (let binY = minBinY; binY <= maxBinY; binY++) {
383
+ for (const obstacleIndex of obstacleIndexesByBin.get(
384
+ `${binX}:${binY}`,
385
+ ) ?? []) {
386
+ indexes.add(obstacleIndex)
387
+ }
388
+ }
389
+ }
390
+ return [...indexes].map((index) => obstacles[index]!)
391
+ }
392
+ const obstacleFreeNodes = new Uint8Array(nodeCount)
393
+ for (let node = 0; node < nodeCount; node++) {
394
+ const point = points[node]!
395
+ if (
396
+ getNearbyObstacles(point).every(
397
+ (obstacle) =>
398
+ distancePointToObstacle(point, obstacle) >=
399
+ requiredObstacleDistance - EPSILON,
400
+ )
401
+ ) {
402
+ obstacleFreeNodes[node] = 1
403
+ }
404
+ }
405
+ const neighbors: number[][] = Array.from({ length: nodeCount }, () => [])
406
+ for (let node = 0; node < nodeCount; node++) {
407
+ if (!obstacleFreeNodes[node]) continue
408
+ const column = node % columnCount
409
+ const row = Math.floor(node / columnCount)
410
+ for (const [deltaColumn, deltaRow] of [
411
+ [1, 0],
412
+ [0, 1],
413
+ ] as const) {
414
+ const nextColumn = column + deltaColumn
415
+ const nextRow = row + deltaRow
416
+ if (nextColumn >= columnCount || nextRow >= rowCount) continue
417
+ const nextNode = nextRow * columnCount + nextColumn
418
+ if (!obstacleFreeNodes[nextNode]) continue
419
+ const segment: RoutedSegment = {
420
+ start: points[node]!,
421
+ end: points[nextNode]!,
422
+ width: traceWidth,
423
+ layer: "top",
424
+ }
425
+ if (
426
+ getNearbyObstacles(segment.start, segment.end).some(
427
+ (obstacle) =>
428
+ distanceSegmentToObstacle(segment, obstacle) <
429
+ requiredObstacleDistance - EPSILON,
430
+ )
431
+ ) {
432
+ continue
433
+ }
434
+ neighbors[node]!.push(nextNode)
435
+ neighbors[nextNode]!.push(node)
436
+ }
437
+ }
438
+ return {
439
+ boundary,
440
+ step,
441
+ columnCount,
442
+ rowCount,
443
+ nodeCount,
444
+ points,
445
+ obstacleFreeNodes,
446
+ neighbors,
447
+ }
448
+ }
449
+
450
+ function routeDirectionGroup(params: {
451
+ direction: FanoutDirection | "any"
452
+ items: FlowItem[]
453
+ grid: FlowGrid
454
+ obstacles: Obstacle[]
455
+ traceWidth: number
456
+ clearance: number
457
+ occupiedNodes: Uint8Array
458
+ acceptedSegments: RoutedSegment[]
459
+ connectorSelectionOffset?: number
460
+ }): DirectionGroupResult | null {
461
+ const {
462
+ direction,
463
+ items,
464
+ grid,
465
+ obstacles,
466
+ traceWidth,
467
+ clearance,
468
+ occupiedNodes,
469
+ acceptedSegments,
470
+ connectorSelectionOffset = 0,
471
+ } = params
472
+ if (items.length === 0) {
473
+ return { routes: [], usedNodes: [], unmatchedItems: [] }
474
+ }
475
+ const {
476
+ boundary,
477
+ step,
478
+ columnCount,
479
+ rowCount,
480
+ nodeCount: gridNodeCount,
481
+ } = grid
482
+ const pointForNode = (node: number): Point2D => grid.points[node]!
483
+ const requiredObstacleDistance = traceWidth / 2 + clearance
484
+ const requiredRouteDistance = traceWidth + clearance
485
+ const freeNodes = new Uint8Array(gridNodeCount)
486
+ for (let node = 0; node < gridNodeCount; node++) {
487
+ if (!occupiedNodes[node] && grid.obstacleFreeNodes[node]) {
488
+ freeNodes[node] = 1
489
+ }
490
+ }
491
+ const connectorIsClear = (points: Point2D[], item: FlowItem) => {
492
+ const segments = getSegments(points, traceWidth)
493
+ return (
494
+ segments.every((segment) =>
495
+ obstacles.every(
496
+ (obstacle) =>
497
+ obstacleBelongsToItem(obstacle, item) ||
498
+ distanceSegmentToObstacle(segment, obstacle) >=
499
+ requiredObstacleDistance - EPSILON,
500
+ ),
501
+ ) &&
502
+ segments.every((segment) =>
503
+ acceptedSegments.every(
504
+ (acceptedSegment) =>
505
+ distanceSegmentToSegment(
506
+ segment.start,
507
+ segment.end,
508
+ acceptedSegment.start,
509
+ acceptedSegment.end,
510
+ ) >=
511
+ requiredRouteDistance - EPSILON,
512
+ ),
513
+ )
514
+ )
515
+ }
516
+
517
+ const equivalentItemsByKey = new Map<string, FlowItem[]>()
518
+ for (const item of items) {
519
+ const key = `${item.source.x.toFixed(6)}:${item.source.y.toFixed(6)}:${item.netKey}`
520
+ const equivalents = equivalentItemsByKey.get(key) ?? []
521
+ equivalents.push(item)
522
+ equivalentItemsByKey.set(key, equivalents)
523
+ }
524
+ const offsetCandidates = Array.from({ length: 61 * 61 }, (_, index) => {
525
+ const x = (index % 61) - 30
526
+ const y = Math.floor(index / 61) - 30
527
+ return { x, y, distanceSquared: x * x + y * y }
528
+ }).sort((first, second) => first.distanceSquared - second.distanceSquared)
529
+ const terminals: FlowTerminal[] = []
530
+ for (const equivalentItems of equivalentItemsByKey.values()) {
531
+ const item = equivalentItems[0]!
532
+ const maxConnectorLength =
533
+ Math.hypot(
534
+ item.connection.sourceObstacle.width / 2,
535
+ item.connection.sourceObstacle.height / 2,
536
+ ) +
537
+ clearance +
538
+ step * 2
539
+ const sourceColumn = Math.round((item.source.x - boundary.minX) / step)
540
+ const sourceRow = Math.round((item.source.y - boundary.minY) / step)
541
+ const candidates: FlowTerminal["candidates"] = []
542
+ const candidateNodes = new Set<number>()
543
+ for (const offset of offsetCandidates) {
544
+ const column = sourceColumn + offset.x
545
+ const row = sourceRow + offset.y
546
+ if (column < 0 || column >= columnCount || row < 0 || row >= rowCount) {
547
+ continue
548
+ }
549
+ const node = row * columnCount + column
550
+ if (!freeNodes[node] || candidateNodes.has(node)) continue
551
+ if (distance(item.source, pointForNode(node)) > maxConnectorLength) {
552
+ continue
553
+ }
554
+ const connectorPoints = connectWith45DegreeSegments(
555
+ item.source,
556
+ pointForNode(node),
557
+ )
558
+ if (!connectorIsClear(connectorPoints, item)) continue
559
+ candidateNodes.add(node)
560
+ candidates.push({ node, connectorPoints })
561
+ if (candidates.length >= 12) break
562
+ }
563
+ if (candidates.length === 0) return null
564
+ terminals.push({ item, equivalentItems, candidates })
565
+ }
566
+
567
+ const selectedConnectorSegments: Array<{
568
+ netKey: string
569
+ segments: RoutedSegment[]
570
+ }> = []
571
+ for (const terminal of [...terminals].sort(
572
+ (first, second) => first.candidates.length - second.candidates.length,
573
+ )) {
574
+ const candidateOffset =
575
+ terminal.candidates.length === 0
576
+ ? 0
577
+ : connectorSelectionOffset % terminal.candidates.length
578
+ const orderedCandidates = [
579
+ ...terminal.candidates.slice(candidateOffset),
580
+ ...terminal.candidates.slice(0, candidateOffset),
581
+ ]
582
+ const candidate = orderedCandidates.find((value) => {
583
+ const segments = getSegments(value.connectorPoints, traceWidth)
584
+ return selectedConnectorSegments.every(
585
+ (selected) =>
586
+ selected.netKey === terminal.item.netKey ||
587
+ segments.every((segment) =>
588
+ selected.segments.every(
589
+ (otherSegment) =>
590
+ distanceSegmentToSegment(
591
+ segment.start,
592
+ segment.end,
593
+ otherSegment.start,
594
+ otherSegment.end,
595
+ ) >=
596
+ requiredRouteDistance - EPSILON,
597
+ ),
598
+ ),
599
+ )
600
+ })
601
+ if (!candidate) return null
602
+ terminal.candidates = [candidate]
603
+ selectedConnectorSegments.push({
604
+ netKey: terminal.item.netKey,
605
+ segments: getSegments(candidate.connectorPoints, traceWidth),
606
+ })
607
+ }
608
+
609
+ const terminalNodes = new Set(
610
+ terminals.map((terminal) => terminal.candidates[0]!.node),
611
+ )
612
+ for (const selected of selectedConnectorSegments) {
613
+ for (const segment of selected.segments) {
614
+ const minColumn = Math.max(
615
+ 0,
616
+ Math.floor(
617
+ (Math.min(segment.start.x, segment.end.x) -
618
+ requiredRouteDistance -
619
+ boundary.minX) /
620
+ step,
621
+ ),
622
+ )
623
+ const maxColumn = Math.min(
624
+ columnCount - 1,
625
+ Math.ceil(
626
+ (Math.max(segment.start.x, segment.end.x) +
627
+ requiredRouteDistance -
628
+ boundary.minX) /
629
+ step,
630
+ ),
631
+ )
632
+ const minRow = Math.max(
633
+ 0,
634
+ Math.floor(
635
+ (Math.min(segment.start.y, segment.end.y) -
636
+ requiredRouteDistance -
637
+ boundary.minY) /
638
+ step,
639
+ ),
640
+ )
641
+ const maxRow = Math.min(
642
+ rowCount - 1,
643
+ Math.ceil(
644
+ (Math.max(segment.start.y, segment.end.y) +
645
+ requiredRouteDistance -
646
+ boundary.minY) /
647
+ step,
648
+ ),
649
+ )
650
+ for (let row = minRow; row <= maxRow; row++) {
651
+ for (let column = minColumn; column <= maxColumn; column++) {
652
+ const node = row * columnCount + column
653
+ if (!freeNodes[node] || terminalNodes.has(node)) continue
654
+ const point = pointForNode(node)
655
+ if (
656
+ distanceSegmentToSegment(segment.start, segment.end, point, point) <
657
+ requiredRouteDistance - EPSILON
658
+ ) {
659
+ freeNodes[node] = 0
660
+ }
661
+ }
662
+ }
663
+ }
664
+ }
665
+
666
+ const source = 0
667
+ const terminalStart = 1
668
+ const gridInStart = terminalStart + terminals.length
669
+ const gridOutStart = gridInStart + gridNodeCount
670
+ const sink = gridOutStart + gridNodeCount
671
+ const flow = new Dinic(sink + 1)
672
+ for (
673
+ let terminalIndex = 0;
674
+ terminalIndex < terminals.length;
675
+ terminalIndex++
676
+ ) {
677
+ const terminalNode = terminalStart + terminalIndex
678
+ flow.addEdge(source, terminalNode, 1)
679
+ for (const candidate of terminals[terminalIndex]!.candidates) {
680
+ flow.addEdge(terminalNode, gridInStart + candidate.node, 1, {
681
+ gridNode: candidate.node,
682
+ })
683
+ }
684
+ }
685
+ for (let node = 0; node < gridNodeCount; node++) {
686
+ if (!freeNodes[node]) continue
687
+ flow.addEdge(gridInStart + node, gridOutStart + node, 1)
688
+ const column = node % columnCount
689
+ const row = Math.floor(node / columnCount)
690
+ for (const nextNode of grid.neighbors[node]!) {
691
+ if (!freeNodes[nextNode]) continue
692
+ flow.addEdge(gridOutStart + node, gridInStart + nextNode, 1, {
693
+ gridNode: nextNode,
694
+ })
695
+ }
696
+ const isTarget =
697
+ (direction === "any" &&
698
+ (column === 0 ||
699
+ column === columnCount - 1 ||
700
+ row === 0 ||
701
+ row === rowCount - 1)) ||
702
+ (direction === "left" && column === 0) ||
703
+ (direction === "right" && column === columnCount - 1) ||
704
+ (direction === "down" && row === 0) ||
705
+ (direction === "up" && row === rowCount - 1)
706
+ if (isTarget) {
707
+ flow.addEdge(gridOutStart + node, sink, 1, { isSink: true })
708
+ }
709
+ }
710
+ const achievedFlow = flow.maximumFlow(source, sink, terminals.length)
711
+ const terminalWasMatched = (terminalIndex: number) => {
712
+ const terminalNode = terminalStart + terminalIndex
713
+ return flow.edges[source]!.some(
714
+ (edge) =>
715
+ edge.to === terminalNode &&
716
+ edge.initialCapacity === 1 &&
717
+ edge.capacity === 0,
718
+ )
719
+ }
720
+ if (achievedFlow !== terminals.length) {
721
+ if (FANOUT_FLOW_DEBUG_ENABLED) {
722
+ const unmatchedConnections = terminals.flatMap((terminal, index) => {
723
+ const terminalNode = terminalStart + index
724
+ return terminalWasMatched(index)
725
+ ? []
726
+ : [terminal.item.connection.connection.name]
727
+ })
728
+ console.error("single-layer flow group failed", {
729
+ direction,
730
+ achievedFlow,
731
+ requiredFlow: terminals.length,
732
+ unmatchedConnections,
733
+ })
734
+ }
735
+ }
736
+
737
+ const routes: FlowRoute[] = []
738
+ const usedNodes: number[] = []
739
+ const unmatchedItems: FlowItem[] = []
740
+ for (
741
+ let terminalIndex = 0;
742
+ terminalIndex < terminals.length;
743
+ terminalIndex++
744
+ ) {
745
+ const terminal = terminals[terminalIndex]!
746
+ if (!terminalWasMatched(terminalIndex)) {
747
+ unmatchedItems.push(...terminal.equivalentItems)
748
+ continue
749
+ }
750
+ const terminalNode = terminalStart + terminalIndex
751
+ const candidateEdge = flow.edges[terminalNode]!.find(
752
+ (edge) =>
753
+ edge.initialCapacity === 1 &&
754
+ edge.capacity === 0 &&
755
+ edge.gridNode !== undefined,
756
+ )
757
+ if (candidateEdge?.gridNode === undefined) return null
758
+ const candidate = terminal.candidates.find(
759
+ (value) => value.node === candidateEdge.gridNode,
760
+ )
761
+ if (!candidate) return null
762
+ const gridPoints: Point2D[] = [pointForNode(candidate.node)]
763
+ let node = candidate.node
764
+ const routeNodes = [node]
765
+ for (let guard = 0; guard <= gridNodeCount; guard++) {
766
+ const outNode = gridOutStart + node
767
+ const nextEdge = flow.edges[outNode]!.find(
768
+ (edge) =>
769
+ edge.initialCapacity === 1 &&
770
+ edge.capacity === 0 &&
771
+ (edge.gridNode !== undefined || edge.isSink),
772
+ )
773
+ if (!nextEdge) return null
774
+ if (nextEdge.isSink) break
775
+ if (nextEdge.gridNode === undefined) return null
776
+ node = nextEdge.gridNode
777
+ routeNodes.push(node)
778
+ gridPoints.push(pointForNode(node))
779
+ }
780
+ usedNodes.push(...routeNodes)
781
+ const unchamferedPoints = compressPath([
782
+ ...candidate.connectorPoints,
783
+ ...gridPoints.slice(1),
784
+ ])
785
+ const points = enforceStraightOr45DegreeSegments(
786
+ chamferOrthogonalPolyline(unchamferedPoints, step / 2),
787
+ )
788
+ const segments = getSegments(points, traceWidth)
789
+ for (const item of terminal.equivalentItems) {
790
+ routes.push({ item, points, segments })
791
+ }
792
+ }
793
+ return { routes, usedNodes, unmatchedItems }
794
+ }
795
+
796
+ function buildPlan(route: FlowRoute, traceWidth: number): FanoutRoutePlan {
797
+ const { item, points, segments } = route
798
+ const traceRoute: SimplifiedPcbTrace["route"] = points.map(
799
+ (point, index) => ({
800
+ route_type: "wire",
801
+ x: point.x,
802
+ y: point.y,
803
+ width: traceWidth,
804
+ layer: "top",
805
+ ...(index === 0 && item.connection.sourcePoint.pcb_port_id
806
+ ? { start_pcb_port_id: item.connection.sourcePoint.pcb_port_id }
807
+ : {}),
808
+ }),
809
+ )
810
+ return {
811
+ busId: item.bus.busId,
812
+ connectionName: item.connection.connection.name,
813
+ connectionIndex: item.connection.connectionIndex,
814
+ sourcePointIndex: item.connection.sourcePointIndex,
815
+ sourcePoint: item.connection.sourcePoint,
816
+ sourceObstacle: item.connection.sourceObstacle,
817
+ sourceLayer: item.connection.sourceLayer,
818
+ targetLayer: "top",
819
+ termination: item.bus.termination,
820
+ direction: item.bus.direction,
821
+ exitPoint: points.at(-1)!,
822
+ trace: {
823
+ type: "pcb_trace",
824
+ pcb_trace_id: `fanout:${item.connection.connection.name}`,
825
+ connection_name: item.connection.connection.name,
826
+ connectsTo: [
827
+ item.connection.connection.name,
828
+ item.netKey,
829
+ ...(item.connection.sourcePoint.pointId
830
+ ? [item.connection.sourcePoint.pointId]
831
+ : []),
832
+ ...(item.connection.sourcePoint.pcb_port_id
833
+ ? [item.connection.sourcePoint.pcb_port_id]
834
+ : []),
835
+ ],
836
+ route: traceRoute,
837
+ },
838
+ segments,
839
+ length: segments.reduce(
840
+ (total, segment) => total + distance(segment.start, segment.end),
841
+ 0,
842
+ ),
843
+ }
844
+ }
845
+
846
+ function getConnectorVariants(start: Point2D, end: Point2D): Point2D[][] {
847
+ const first = connectWith45DegreeSegments(start, end)
848
+ const second = connectWith45DegreeSegments(end, start).reverse()
849
+ return first.length === second.length &&
850
+ first.every((point, index) => distance(point, second[index]!) < EPSILON)
851
+ ? [first]
852
+ : [first, second]
853
+ }
854
+
855
+ function plansHaveRequiredClearance(params: {
856
+ plans: FanoutRoutePlan[]
857
+ items: FlowItem[]
858
+ obstacles: Obstacle[]
859
+ traceWidth: number
860
+ clearance: number
861
+ }): boolean {
862
+ const { plans, items, obstacles, traceWidth, clearance } = params
863
+ const netKeyByConnectionName = new Map(
864
+ items.map((item) => [item.connection.connection.name, item.netKey]),
865
+ )
866
+ const uniqueSegmentsByNet = new Map<string, RoutedSegment[]>()
867
+ const segmentKeysByNet = new Map<string, Set<string>>()
868
+ for (const plan of plans) {
869
+ const netKey = netKeyByConnectionName.get(plan.connectionName)!
870
+ const segments = uniqueSegmentsByNet.get(netKey) ?? []
871
+ const segmentKeys = segmentKeysByNet.get(netKey) ?? new Set<string>()
872
+ for (const segment of plan.segments) {
873
+ const endpoints = [segment.start, segment.end]
874
+ .map((point) => `${point.x.toFixed(6)}:${point.y.toFixed(6)}`)
875
+ .sort()
876
+ const key = endpoints.join(":")
877
+ if (segmentKeys.has(key)) continue
878
+ segmentKeys.add(key)
879
+ segments.push(segment)
880
+ }
881
+ uniqueSegmentsByNet.set(netKey, segments)
882
+ segmentKeysByNet.set(netKey, segmentKeys)
883
+ }
884
+ const requiredObstacleDistance = traceWidth / 2 + clearance
885
+ for (const [netKey, segments] of uniqueSegmentsByNet) {
886
+ for (const segment of segments) {
887
+ for (const obstacle of obstacles) {
888
+ if (
889
+ obstacle.connectedTo.includes(netKey) ||
890
+ distanceSegmentToObstacle(segment, obstacle) >=
891
+ requiredObstacleDistance - EPSILON
892
+ ) {
893
+ continue
894
+ }
895
+ if (FANOUT_FLOW_DEBUG_ENABLED) {
896
+ console.error("single-layer route violates obstacle clearance", {
897
+ netKey,
898
+ segment,
899
+ obstacleId: obstacle.obstacleId,
900
+ distance: distanceSegmentToObstacle(segment, obstacle),
901
+ requiredObstacleDistance,
902
+ })
903
+ }
904
+ return false
905
+ }
906
+ }
907
+ }
908
+ const requiredRouteDistance = traceWidth + clearance
909
+ const entries = [...uniqueSegmentsByNet]
910
+ for (let firstIndex = 0; firstIndex < entries.length; firstIndex++) {
911
+ for (
912
+ let secondIndex = firstIndex + 1;
913
+ secondIndex < entries.length;
914
+ secondIndex++
915
+ ) {
916
+ for (const first of entries[firstIndex]![1]) {
917
+ for (const second of entries[secondIndex]![1]) {
918
+ if (
919
+ distanceSegmentToSegment(
920
+ first.start,
921
+ first.end,
922
+ second.start,
923
+ second.end,
924
+ ) <
925
+ requiredRouteDistance - EPSILON
926
+ ) {
927
+ if (FANOUT_FLOW_DEBUG_ENABLED) {
928
+ console.error("single-layer routes violate copper clearance", {
929
+ firstNetKey: entries[firstIndex]![0],
930
+ secondNetKey: entries[secondIndex]![0],
931
+ first,
932
+ second,
933
+ distance: distanceSegmentToSegment(
934
+ first.start,
935
+ first.end,
936
+ second.start,
937
+ second.end,
938
+ ),
939
+ requiredRouteDistance,
940
+ })
941
+ }
942
+ return false
943
+ }
944
+ }
945
+ }
946
+ }
947
+ }
948
+ return true
949
+ }
950
+
951
+ function getDirectionForBoundaryPoint(
952
+ point: Point2D,
953
+ boundary: PreparedBus["sharedBoundary"],
954
+ ): FanoutDirection | null {
955
+ if (Math.abs(point.x - boundary.minX) < EPSILON) return "left"
956
+ if (Math.abs(point.x - boundary.maxX) < EPSILON) return "right"
957
+ if (Math.abs(point.y - boundary.minY) < EPSILON) return "down"
958
+ if (Math.abs(point.y - boundary.maxY) < EPSILON) return "up"
959
+ return null
960
+ }
961
+
962
+ function routeWithAdaptiveExits(params: {
963
+ items: FlowItem[]
964
+ grid: FlowGrid
965
+ obstacles: Obstacle[]
966
+ traceWidth: number
967
+ clearance: number
968
+ }): FanoutRoutePlan[] | null {
969
+ const { items, grid, obstacles, traceWidth, clearance } = params
970
+ const mergeItems = new Set(
971
+ items.filter(
972
+ (item) =>
973
+ item.connection.sourceObstacle.width > 2 &&
974
+ item.connection.sourceObstacle.height > 2,
975
+ ),
976
+ )
977
+ let unrestricted: ReturnType<typeof routeDirectionGroup> = null
978
+ for (let mergeRound = 0; mergeRound < 4; mergeRound++) {
979
+ const directlyRoutedItems = items.filter((item) => !mergeItems.has(item))
980
+ let bestResult: DirectionGroupResult | null = null
981
+ for (
982
+ let connectorSelectionOffset = 0;
983
+ connectorSelectionOffset < 4;
984
+ connectorSelectionOffset++
985
+ ) {
986
+ const result = routeDirectionGroup({
987
+ direction: "any",
988
+ items: directlyRoutedItems,
989
+ grid,
990
+ obstacles,
991
+ traceWidth,
992
+ clearance,
993
+ occupiedNodes: new Uint8Array(grid.nodeCount),
994
+ acceptedSegments: [],
995
+ connectorSelectionOffset,
996
+ })
997
+ if (!result) continue
998
+ if (!bestResult || result.routes.length > bestResult.routes.length) {
999
+ bestResult = result
1000
+ }
1001
+ if (result.routes.length === directlyRoutedItems.length) break
1002
+ }
1003
+ if (!bestResult) return null
1004
+ if (bestResult.routes.length === directlyRoutedItems.length) {
1005
+ unrestricted = bestResult
1006
+ break
1007
+ }
1008
+ const directlyRoutedNetCounts = new Map<string, number>()
1009
+ for (const item of directlyRoutedItems) {
1010
+ directlyRoutedNetCounts.set(
1011
+ item.netKey,
1012
+ (directlyRoutedNetCounts.get(item.netKey) ?? 0) + 1,
1013
+ )
1014
+ }
1015
+ let addedMergeItem = false
1016
+ for (const item of bestResult.unmatchedItems) {
1017
+ if ((directlyRoutedNetCounts.get(item.netKey) ?? 0) < 2) continue
1018
+ mergeItems.add(item)
1019
+ addedMergeItem = true
1020
+ }
1021
+ if (!addedMergeItem) return null
1022
+ }
1023
+ if (!unrestricted) return null
1024
+ const routes = [...unrestricted.routes]
1025
+ const requiredObstacleDistance = traceWidth / 2 + clearance
1026
+ const requiredRouteDistance = traceWidth + clearance
1027
+ for (const mergeItem of mergeItems) {
1028
+ const candidates = routes
1029
+ .filter((route) => route.item.netKey === mergeItem.netKey)
1030
+ .flatMap((route) =>
1031
+ route.points.map((point, pointIndex) => ({
1032
+ route,
1033
+ point,
1034
+ pointIndex,
1035
+ })),
1036
+ )
1037
+ .sort(
1038
+ (first, second) =>
1039
+ Number(
1040
+ second.route.item.bus.componentId === mergeItem.bus.componentId,
1041
+ ) -
1042
+ Number(
1043
+ first.route.item.bus.componentId === mergeItem.bus.componentId,
1044
+ ) ||
1045
+ distance(mergeItem.source, first.point) -
1046
+ distance(mergeItem.source, second.point),
1047
+ )
1048
+ let mergedRoute: FlowRoute | null = null
1049
+ for (const candidate of candidates) {
1050
+ for (const connectorPoints of getConnectorVariants(
1051
+ mergeItem.source,
1052
+ candidate.point,
1053
+ )) {
1054
+ const connectorSegments = getSegments(connectorPoints, traceWidth)
1055
+ const clearsObstacles = connectorSegments.every((segment) =>
1056
+ obstacles.every(
1057
+ (obstacle) =>
1058
+ obstacleBelongsToItem(obstacle, mergeItem) ||
1059
+ distanceSegmentToObstacle(segment, obstacle) >=
1060
+ requiredObstacleDistance - EPSILON,
1061
+ ),
1062
+ )
1063
+ const clearsRoutes = connectorSegments.every((segment) =>
1064
+ routes.every(
1065
+ (route) =>
1066
+ route.item.netKey === mergeItem.netKey ||
1067
+ route.segments.every(
1068
+ (otherSegment) =>
1069
+ distanceSegmentToSegment(
1070
+ segment.start,
1071
+ segment.end,
1072
+ otherSegment.start,
1073
+ otherSegment.end,
1074
+ ) >=
1075
+ requiredRouteDistance - EPSILON,
1076
+ ),
1077
+ ),
1078
+ )
1079
+ if (!clearsObstacles || !clearsRoutes) continue
1080
+ const points = enforceStraightOr45DegreeSegments(
1081
+ compressPath([
1082
+ ...connectorPoints,
1083
+ ...candidate.route.points.slice(candidate.pointIndex + 1),
1084
+ ]),
1085
+ )
1086
+ mergedRoute = {
1087
+ item: mergeItem,
1088
+ points,
1089
+ segments: getSegments(points, traceWidth),
1090
+ }
1091
+ break
1092
+ }
1093
+ if (mergedRoute) break
1094
+ }
1095
+ if (!mergedRoute) return null
1096
+ routes.push(mergedRoute)
1097
+ }
1098
+ const plans = routes.map((route) => buildPlan(route, traceWidth))
1099
+ if (
1100
+ !plansHaveRequiredClearance({
1101
+ plans,
1102
+ items,
1103
+ obstacles,
1104
+ traceWidth,
1105
+ clearance,
1106
+ })
1107
+ ) {
1108
+ if (FANOUT_FLOW_DEBUG_ENABLED) {
1109
+ console.error("single-layer adaptive exits failed exact clearance")
1110
+ }
1111
+ return null
1112
+ }
1113
+ for (const plan of plans) {
1114
+ const direction = getDirectionForBoundaryPoint(
1115
+ plan.exitPoint,
1116
+ grid.boundary,
1117
+ )
1118
+ if (!direction) return null
1119
+ const item = items.find(
1120
+ (candidate) =>
1121
+ candidate.connection.connection.name === plan.connectionName,
1122
+ )!
1123
+ item.bus.direction = direction
1124
+ item.bus.preferredExit =
1125
+ direction === "up" ? "top" : direction === "down" ? "bottom" : direction
1126
+ plan.direction = direction
1127
+ }
1128
+ const planByConnectionName = new Map(
1129
+ plans.map((plan) => [plan.connectionName, plan]),
1130
+ )
1131
+ return items.map(
1132
+ (item) => planByConnectionName.get(item.connection.connection.name)!,
1133
+ )
1134
+ }
1135
+
1136
+ export function routeSingleLayerWithAdaptiveExits(
1137
+ params: FlowRoutingParams,
1138
+ ): FanoutRoutePlan[] | null {
1139
+ const { srj, buses, traceWidth, clearance } = params
1140
+ if (buses.some((bus) => bus.connections.length !== 1)) return null
1141
+ const items = buses.flatMap((bus) =>
1142
+ bus.connections.map(
1143
+ (connection): FlowItem => ({
1144
+ bus,
1145
+ connection,
1146
+ source: {
1147
+ x: connection.sourcePoint.x,
1148
+ y: connection.sourcePoint.y,
1149
+ },
1150
+ netKey: getNetKey(connection),
1151
+ }),
1152
+ ),
1153
+ )
1154
+ const topObstacles = srj.obstacles.filter((obstacle) =>
1155
+ obstacle.layers.includes("top"),
1156
+ )
1157
+ const boundary = buses[0]?.sharedBoundary
1158
+ if (!boundary) return []
1159
+ const grid = createFlowGrid({
1160
+ boundary,
1161
+ obstacles: topObstacles,
1162
+ traceWidth,
1163
+ clearance,
1164
+ })
1165
+ if (FANOUT_FLOW_DEBUG_ENABLED) {
1166
+ console.error("single-layer adaptive-exit grid ready", {
1167
+ nodeCount: grid.nodeCount,
1168
+ })
1169
+ }
1170
+ const adaptivePlans = routeWithAdaptiveExits({
1171
+ items,
1172
+ grid,
1173
+ obstacles: topObstacles,
1174
+ traceWidth,
1175
+ clearance,
1176
+ })
1177
+ if (adaptivePlans) return adaptivePlans
1178
+ return null
1179
+ }