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