altium-toolkit 1.1.36 → 1.1.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.
- package/AGENTS.md +15 -0
- package/package.json +1 -1
- package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +245 -12
- package/src/ui/AltiumScene3dAuthoredConnectorYawPolicy.mjs +176 -0
- package/src/ui/AltiumScene3dBottomSourceHalfTurnPolicy.mjs +93 -0
- package/src/ui/AltiumScene3dDisplayModuleYawPolicy.mjs +284 -0
- package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +521 -23
- package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +766 -19
- package/src/ui/AltiumScene3dQfnFootprintDetector.mjs +307 -0
- package/src/ui/AltiumScene3dRepeatedFullFootprintBodyCollapse.mjs +518 -0
- package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +121 -17
- package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +180 -158
- package/src/ui/AltiumScene3dShapeStackOwnerConflictPolicy.mjs +299 -0
- package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +22 -0
- package/src/ui/PcbScene3dBuilder.mjs +613 -117
- package/src/ui/PcbScene3dModelRegistry.mjs +222 -10
- package/src/ui/PcbScene3dPlacementSideResolver.mjs +262 -22
- package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +87 -2
- package/src/ui/PcbScene3dStaticBodyPadOwnerPromotion.mjs +378 -0
- package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +32 -16
- package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +1 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects QFN-style exposed-pad footprints from normalized PCB pads.
|
|
3
|
+
*/
|
|
4
|
+
export class AltiumScene3dQfnFootprintDetector {
|
|
5
|
+
/**
|
|
6
|
+
* Checks whether a component owns a centered exposed pad plus source-ordered
|
|
7
|
+
* perimeter pads around the package edges.
|
|
8
|
+
* @param {object} component PCB component.
|
|
9
|
+
* @param {object[]} pads Source PCB pads.
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
static hasExposedPadPerimeterSequence(component, pads) {
|
|
13
|
+
const surfacePads = AltiumScene3dQfnFootprintDetector.#surfacePads(
|
|
14
|
+
component,
|
|
15
|
+
pads
|
|
16
|
+
).filter((pad) =>
|
|
17
|
+
AltiumScene3dQfnFootprintDetector.#hasFinitePosition(pad)
|
|
18
|
+
)
|
|
19
|
+
if (surfacePads.length < 9) {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const centerPad = AltiumScene3dQfnFootprintDetector.#centerPad(
|
|
24
|
+
component,
|
|
25
|
+
surfacePads
|
|
26
|
+
)
|
|
27
|
+
if (!centerPad) {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const perimeterPads = surfacePads.filter((pad) => pad !== centerPad)
|
|
32
|
+
if (perimeterPads.length < 8) {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const perimeterBounds =
|
|
37
|
+
AltiumScene3dQfnFootprintDetector.#bounds(perimeterPads)
|
|
38
|
+
if (
|
|
39
|
+
!AltiumScene3dQfnFootprintDetector.#hasBalancedPerimeter(
|
|
40
|
+
perimeterBounds
|
|
41
|
+
)
|
|
42
|
+
) {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const edgeSequence = AltiumScene3dQfnFootprintDetector.#edgeSequence(
|
|
47
|
+
perimeterPads,
|
|
48
|
+
perimeterBounds
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return AltiumScene3dQfnFootprintDetector.#isOrderedRing(edgeSequence)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Collects surface pads owned by one component.
|
|
56
|
+
* @param {object} component PCB component.
|
|
57
|
+
* @param {object[]} pads Source PCB pads.
|
|
58
|
+
* @returns {object[]}
|
|
59
|
+
*/
|
|
60
|
+
static #surfacePads(component, pads) {
|
|
61
|
+
const componentIndex = Number(component?.componentIndex)
|
|
62
|
+
if (!Number.isFinite(componentIndex)) {
|
|
63
|
+
return []
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ownedPads = (Array.isArray(pads) ? pads : []).filter(
|
|
67
|
+
(pad) => Number(pad?.componentIndex) === componentIndex
|
|
68
|
+
)
|
|
69
|
+
const bottom =
|
|
70
|
+
String(component?.layer || '')
|
|
71
|
+
.toUpperCase()
|
|
72
|
+
.includes('BOTTOM') ||
|
|
73
|
+
String(component?.layer || '').toUpperCase() === 'BOT'
|
|
74
|
+
const surfacePads = ownedPads.filter((pad) =>
|
|
75
|
+
bottom
|
|
76
|
+
? Boolean(pad?.hasBottomPasteMaskOpening)
|
|
77
|
+
: Boolean(pad?.hasTopPasteMaskOpening)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return surfacePads.length ? surfacePads : ownedPads
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Finds an exposed pad close to the component origin and larger than the
|
|
85
|
+
* perimeter pads.
|
|
86
|
+
* @param {object} component PCB component.
|
|
87
|
+
* @param {object[]} surfacePads Surface pads owned by the component.
|
|
88
|
+
* @returns {object | null}
|
|
89
|
+
*/
|
|
90
|
+
static #centerPad(component, surfacePads) {
|
|
91
|
+
const componentX = Number(component?.x)
|
|
92
|
+
const componentY = Number(component?.y)
|
|
93
|
+
if (!Number.isFinite(componentX) || !Number.isFinite(componentY)) {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const footprintBounds =
|
|
98
|
+
AltiumScene3dQfnFootprintDetector.#bounds(surfacePads)
|
|
99
|
+
const diagonal = Math.hypot(
|
|
100
|
+
footprintBounds.maxX - footprintBounds.minX,
|
|
101
|
+
footprintBounds.maxY - footprintBounds.minY
|
|
102
|
+
)
|
|
103
|
+
const centerTolerance = Math.max(2, diagonal * 0.05)
|
|
104
|
+
const candidates = surfacePads
|
|
105
|
+
.map((pad) => ({
|
|
106
|
+
pad,
|
|
107
|
+
distance: Math.hypot(
|
|
108
|
+
Number(pad?.x) - componentX,
|
|
109
|
+
Number(pad?.y) - componentY
|
|
110
|
+
),
|
|
111
|
+
area: AltiumScene3dQfnFootprintDetector.#padArea(pad)
|
|
112
|
+
}))
|
|
113
|
+
.filter(
|
|
114
|
+
(candidate) =>
|
|
115
|
+
candidate.distance <= centerTolerance && candidate.area > 0
|
|
116
|
+
)
|
|
117
|
+
.sort(
|
|
118
|
+
(first, second) =>
|
|
119
|
+
first.distance - second.distance || second.area - first.area
|
|
120
|
+
)
|
|
121
|
+
if (!candidates.length) {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const centerCandidate = candidates[0]
|
|
126
|
+
const perimeterAreas = surfacePads
|
|
127
|
+
.filter((pad) => pad !== centerCandidate.pad)
|
|
128
|
+
.map((pad) => AltiumScene3dQfnFootprintDetector.#padArea(pad))
|
|
129
|
+
.filter((area) => area > 0)
|
|
130
|
+
if (!perimeterAreas.length) {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return centerCandidate.area >
|
|
135
|
+
AltiumScene3dQfnFootprintDetector.#median(perimeterAreas) * 1.5
|
|
136
|
+
? centerCandidate.pad
|
|
137
|
+
: null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Checks whether perimeter pads form a roughly square or rectangular ring.
|
|
142
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds Pad bounds.
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
static #hasBalancedPerimeter(bounds) {
|
|
146
|
+
const spreadX = bounds.maxX - bounds.minX
|
|
147
|
+
const spreadY = bounds.maxY - bounds.minY
|
|
148
|
+
if (spreadX <= 0 || spreadY <= 0) {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const ratio = spreadX / spreadY
|
|
153
|
+
|
|
154
|
+
return ratio >= 0.6 && ratio <= 1.67
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Resolves the compressed edge labels in source pad order.
|
|
159
|
+
* @param {object[]} perimeterPads Perimeter pads in source order.
|
|
160
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds Pad bounds.
|
|
161
|
+
* @returns {string[]}
|
|
162
|
+
*/
|
|
163
|
+
static #edgeSequence(perimeterPads, bounds) {
|
|
164
|
+
const spread = Math.max(
|
|
165
|
+
bounds.maxX - bounds.minX,
|
|
166
|
+
bounds.maxY - bounds.minY
|
|
167
|
+
)
|
|
168
|
+
const edgeTolerance = Math.max(2, spread * 0.12)
|
|
169
|
+
const sequence = []
|
|
170
|
+
|
|
171
|
+
for (const pad of perimeterPads) {
|
|
172
|
+
const edge = AltiumScene3dQfnFootprintDetector.#nearestEdge(
|
|
173
|
+
pad,
|
|
174
|
+
bounds,
|
|
175
|
+
edgeTolerance
|
|
176
|
+
)
|
|
177
|
+
if (!edge) {
|
|
178
|
+
return []
|
|
179
|
+
}
|
|
180
|
+
if (sequence.at(-1) !== edge) {
|
|
181
|
+
sequence.push(edge)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return sequence
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Finds the closest perimeter edge for one pad.
|
|
190
|
+
* @param {object} pad Source PCB pad.
|
|
191
|
+
* @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds Pad bounds.
|
|
192
|
+
* @param {number} tolerance Maximum distance from a perimeter edge.
|
|
193
|
+
* @returns {string | null}
|
|
194
|
+
*/
|
|
195
|
+
static #nearestEdge(pad, bounds, tolerance) {
|
|
196
|
+
const distances = [
|
|
197
|
+
['right', Math.abs(bounds.maxX - Number(pad?.x))],
|
|
198
|
+
['top', Math.abs(bounds.maxY - Number(pad?.y))],
|
|
199
|
+
['left', Math.abs(Number(pad?.x) - bounds.minX)],
|
|
200
|
+
['bottom', Math.abs(Number(pad?.y) - bounds.minY)]
|
|
201
|
+
].sort((first, second) => first[1] - second[1])
|
|
202
|
+
|
|
203
|
+
return distances[0][1] <= tolerance ? distances[0][0] : null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Checks whether compressed edge labels follow a perimeter walk.
|
|
208
|
+
* @param {string[]} sequence Compressed edge labels.
|
|
209
|
+
* @returns {boolean}
|
|
210
|
+
*/
|
|
211
|
+
static #isOrderedRing(sequence) {
|
|
212
|
+
if (sequence.length !== 4) {
|
|
213
|
+
return false
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const clockwise = ['right', 'bottom', 'left', 'top']
|
|
217
|
+
const counterClockwise = ['right', 'top', 'left', 'bottom']
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
AltiumScene3dQfnFootprintDetector.#isRotationOf(
|
|
221
|
+
sequence,
|
|
222
|
+
clockwise
|
|
223
|
+
) ||
|
|
224
|
+
AltiumScene3dQfnFootprintDetector.#isRotationOf(
|
|
225
|
+
sequence,
|
|
226
|
+
counterClockwise
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Checks whether one sequence is a rotation of another sequence.
|
|
233
|
+
* @param {string[]} candidate Candidate sequence.
|
|
234
|
+
* @param {string[]} ordered Ordered sequence.
|
|
235
|
+
* @returns {boolean}
|
|
236
|
+
*/
|
|
237
|
+
static #isRotationOf(candidate, ordered) {
|
|
238
|
+
return ordered.some((_, index) =>
|
|
239
|
+
candidate.every(
|
|
240
|
+
(value, candidateIndex) =>
|
|
241
|
+
value === ordered[(index + candidateIndex) % ordered.length]
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Measures pad bounds.
|
|
248
|
+
* @param {object[]} pads Source PCB pads.
|
|
249
|
+
* @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
|
|
250
|
+
*/
|
|
251
|
+
static #bounds(pads) {
|
|
252
|
+
const xs = pads.map((pad) => Number(pad?.x))
|
|
253
|
+
const ys = pads.map((pad) => Number(pad?.y))
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
minX: Math.min(...xs),
|
|
257
|
+
maxX: Math.max(...xs),
|
|
258
|
+
minY: Math.min(...ys),
|
|
259
|
+
maxY: Math.max(...ys)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Checks whether a pad has finite center coordinates.
|
|
265
|
+
* @param {object} pad Source PCB pad.
|
|
266
|
+
* @returns {boolean}
|
|
267
|
+
*/
|
|
268
|
+
static #hasFinitePosition(pad) {
|
|
269
|
+
return (
|
|
270
|
+
Number.isFinite(Number(pad?.x)) && Number.isFinite(Number(pad?.y))
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Measures the largest available pad area.
|
|
276
|
+
* @param {object} pad Source PCB pad.
|
|
277
|
+
* @returns {number}
|
|
278
|
+
*/
|
|
279
|
+
static #padArea(pad) {
|
|
280
|
+
const width = Math.max(
|
|
281
|
+
Number(pad?.sizeTopX || 0),
|
|
282
|
+
Number(pad?.sizeMidX || 0),
|
|
283
|
+
Number(pad?.sizeBottomX || 0)
|
|
284
|
+
)
|
|
285
|
+
const depth = Math.max(
|
|
286
|
+
Number(pad?.sizeTopY || 0),
|
|
287
|
+
Number(pad?.sizeMidY || 0),
|
|
288
|
+
Number(pad?.sizeBottomY || 0)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return width * depth
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Calculates the median of a non-empty numeric array.
|
|
296
|
+
* @param {number[]} values Values to inspect.
|
|
297
|
+
* @returns {number}
|
|
298
|
+
*/
|
|
299
|
+
static #median(values) {
|
|
300
|
+
const sorted = [...values].sort((first, second) => first - second)
|
|
301
|
+
const midpoint = Math.floor(sorted.length / 2)
|
|
302
|
+
|
|
303
|
+
return sorted.length % 2 === 0
|
|
304
|
+
? (sorted[midpoint - 1] + sorted[midpoint]) / 2
|
|
305
|
+
: sorted[midpoint]
|
|
306
|
+
}
|
|
307
|
+
}
|