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.
@@ -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
+ }