altium-toolkit 1.1.26 → 1.1.30

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,105 @@
1
+ const GENERIC_IDENTITY_TOKENS = new Set([
2
+ 'body',
3
+ 'component',
4
+ 'crystal',
5
+ 'footprint',
6
+ 'library',
7
+ 'metric',
8
+ 'model',
9
+ 'package',
10
+ 'series'
11
+ ])
12
+ const GENERIC_PACKAGE_PREFIX_TOKENS = new Set([
13
+ ...GENERIC_IDENTITY_TOKENS,
14
+ 'user'
15
+ ])
16
+
17
+ /**
18
+ * Builds normalized Altium 3D body identity tokens for metadata matching.
19
+ */
20
+ export class AltiumScene3dIdentityTokens {
21
+ /**
22
+ * Creates both full and delimiter-split identity tokens from one source.
23
+ * @param {string} value Source text.
24
+ * @returns {string[]}
25
+ */
26
+ static fromText(value) {
27
+ const baseText = String(value || '')
28
+ .replace(/\.[^.]+$/, '')
29
+ .trim()
30
+ const fullToken = AltiumScene3dIdentityTokens.#normalize([baseText])
31
+ const normalizedParts = baseText
32
+ .split(/[^a-zA-Z0-9]+/g)
33
+ .map((part) => AltiumScene3dIdentityTokens.#normalize([part]))
34
+ const packageTokens =
35
+ AltiumScene3dIdentityTokens.#packageTokensForParts(normalizedParts)
36
+
37
+ return [
38
+ ...new Set([
39
+ ...[fullToken, ...normalizedParts].filter((token) =>
40
+ AltiumScene3dIdentityTokens.#isMeaningful(token)
41
+ ),
42
+ ...packageTokens
43
+ ])
44
+ ]
45
+ }
46
+
47
+ /**
48
+ * Creates compact package-code tokens after generic library words are
49
+ * removed.
50
+ * @param {string[]} parts Normalized identity parts.
51
+ * @returns {string[]}
52
+ */
53
+ static #packageTokensForParts(parts) {
54
+ const meaningfulParts = (Array.isArray(parts) ? parts : []).filter(
55
+ (part) => part && !GENERIC_PACKAGE_PREFIX_TOKENS.has(part)
56
+ )
57
+ const tokens = []
58
+
59
+ for (let index = 0; index < meaningfulParts.length - 1; index += 1) {
60
+ const token = meaningfulParts.slice(index).join('')
61
+ if (AltiumScene3dIdentityTokens.#isMeaningfulPackageCode(token)) {
62
+ tokens.push(token)
63
+ }
64
+ }
65
+
66
+ return tokens
67
+ }
68
+
69
+ /**
70
+ * Checks whether one compact package code is strong enough for metadata
71
+ * matching.
72
+ * @param {string} token Normalized token.
73
+ * @returns {boolean}
74
+ */
75
+ static #isMeaningfulPackageCode(token) {
76
+ return (
77
+ token.length >= 4 &&
78
+ /[a-z]/u.test(token) &&
79
+ /\d/u.test(token) &&
80
+ !GENERIC_IDENTITY_TOKENS.has(token)
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Checks whether one identity token is strong enough for metadata matching.
86
+ * @param {string} token Normalized token.
87
+ * @returns {boolean}
88
+ */
89
+ static #isMeaningful(token) {
90
+ return token.length >= 6 && !GENERIC_IDENTITY_TOKENS.has(token)
91
+ }
92
+
93
+ /**
94
+ * Normalizes identity strings for exact substring matching.
95
+ * @param {unknown[]} values Source values.
96
+ * @returns {string}
97
+ */
98
+ static #normalize(values) {
99
+ return values
100
+ .map((value) => String(value || '').toLowerCase())
101
+ .join(' ')
102
+ .replace(/\.[a-z0-9]+\\b/g, '')
103
+ .replace(/[^a-z0-9]+/g, '')
104
+ }
105
+ }
@@ -0,0 +1,357 @@
1
+ const PASSIVE_BODY_PATTERN =
2
+ /(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|crystal|xtal|lqw|lqg)(?:$|[^a-z0-9])/i
3
+ const PIN_ONE_CORNER_PACKAGE_PATTERN =
4
+ /(?:^|[^a-z0-9])(?:[avw]?qfn|vfqfn|wqfn|v?qfp|lqfp|tqfp|pqfp|mqfp)(?:[0-9]+)?(?:$|[^a-z0-9])/i
5
+ const FIVE_LEAD_SOT_PATTERN =
6
+ /(?:^|[^a-z0-9])sot[-_ ]?(?:23[-_ ]?5|25|5)(?:$|[^a-z0-9])/i
7
+ const EDGE_CONNECTOR_TOKENS = new Set([
8
+ 'antenna',
9
+ 'coax',
10
+ 'connector',
11
+ 'edge',
12
+ 'rf',
13
+ 'sma',
14
+ 'socket'
15
+ ])
16
+
17
+ /**
18
+ * Resolves generic Altium external-model yaw correction rules.
19
+ */
20
+ export class AltiumScene3dPlacementRotationPolicy {
21
+ /**
22
+ * Checks whether an external placement needs a half-turn yaw correction.
23
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[], isExactAnchoredOwner?: boolean }} context Rotation context.
24
+ * @returns {boolean}
25
+ */
26
+ static shouldCorrectYaw(context) {
27
+ return (
28
+ AltiumScene3dPlacementRotationPolicy.#needsSquarePinOneCorrection(
29
+ context
30
+ ) ||
31
+ AltiumScene3dPlacementRotationPolicy.#needsFiveLeadSotCorrection(
32
+ context
33
+ ) ||
34
+ AltiumScene3dPlacementRotationPolicy.#needsTiltedEdgeCorrection(
35
+ context
36
+ )
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Detects square IC packages whose embedded source frame places the
42
+ * pin-one marker opposite the rendered footprint.
43
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null, isExactAnchoredOwner?: boolean }} context Rotation context.
44
+ * @returns {boolean}
45
+ */
46
+ static #needsSquarePinOneCorrection(context) {
47
+ const { placement, component, componentBody } = context || {}
48
+ if (
49
+ !AltiumScene3dPlacementRotationPolicy.#isAnchoredOrPadFallback(
50
+ context
51
+ ) ||
52
+ !component ||
53
+ AltiumScene3dPlacementRotationPolicy.#isGenericPassiveBody(
54
+ componentBody
55
+ )
56
+ ) {
57
+ return false
58
+ }
59
+
60
+ return PIN_ONE_CORNER_PACKAGE_PATTERN.test(
61
+ AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
62
+ component,
63
+ componentBody
64
+ )
65
+ )
66
+ ? AltiumScene3dPlacementRotationPolicy.#hasSquarePinOneFrameMismatch(
67
+ placement,
68
+ component,
69
+ componentBody
70
+ )
71
+ : false
72
+ }
73
+
74
+ /**
75
+ * Checks whether a square package side/source-frame combination needs a
76
+ * half-turn to align model pin one with the footprint convention.
77
+ * @param {object | undefined} placement External model placement.
78
+ * @param {object} component PCB component.
79
+ * @param {object | null | undefined} componentBody Source component body.
80
+ * @returns {boolean}
81
+ */
82
+ static #hasSquarePinOneFrameMismatch(placement, component, componentBody) {
83
+ const mountSide = String(placement?.mountSide || '').toLowerCase()
84
+ if (mountSide === 'top') {
85
+ return true
86
+ }
87
+
88
+ return (
89
+ mountSide === 'bottom' &&
90
+ AltiumScene3dPlacementRotationPolicy.#isHalfTurnAngle(
91
+ placement?.rotationDeg
92
+ ) &&
93
+ AltiumScene3dPlacementRotationPolicy.#isHalfTurnAngle(
94
+ component?.rotation
95
+ ) &&
96
+ AltiumScene3dPlacementRotationPolicy.#isHalfTurnAngle(
97
+ componentBody?.modelRotationDeg?.z ??
98
+ placement?.modelTransform?.rotationDeg?.z
99
+ )
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Detects exact five-lead SOT packages whose STEP source pin-one
105
+ * convention is opposite the asymmetric footprint pad convention.
106
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[], isExactAnchoredOwner?: boolean }} context Rotation context.
107
+ * @returns {boolean}
108
+ */
109
+ static #needsFiveLeadSotCorrection(context) {
110
+ const { component, componentBody, pads } = context || {}
111
+ if (
112
+ !AltiumScene3dPlacementRotationPolicy.#isAnchoredOrPadFallback(
113
+ context
114
+ ) ||
115
+ !component ||
116
+ !FIVE_LEAD_SOT_PATTERN.test(
117
+ AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
118
+ component,
119
+ componentBody
120
+ )
121
+ )
122
+ ) {
123
+ return false
124
+ }
125
+
126
+ return AltiumScene3dPlacementRotationPolicy.#hasAsymmetricFivePads(
127
+ component,
128
+ pads
129
+ )
130
+ }
131
+
132
+ /**
133
+ * Detects top-side edge connectors whose tilted model frame points inward
134
+ * unless the authored board-facing yaw is reversed.
135
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null }} context Rotation context.
136
+ * @returns {boolean}
137
+ */
138
+ static #needsTiltedEdgeCorrection(context) {
139
+ const { placement, component, componentBody } = context || {}
140
+ if (
141
+ !component ||
142
+ String(placement?.mountSide || '').toLowerCase() !== 'top' ||
143
+ !AltiumScene3dPlacementRotationPolicy.#hasInEnvelopeNegativeStandoff(
144
+ componentBody
145
+ ) ||
146
+ !AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
147
+ componentBody
148
+ )
149
+ ) {
150
+ return false
151
+ }
152
+
153
+ const identityText =
154
+ AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
155
+ component,
156
+ componentBody
157
+ )
158
+
159
+ return (
160
+ AltiumScene3dPlacementRotationPolicy.#edgeConnectorTokenCount(
161
+ identityText
162
+ ) >= 2
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Checks whether one placement is exact-anchored or pad-fallback projected.
168
+ * @param {{ placement?: object, isExactAnchoredOwner?: boolean }} context Rotation context.
169
+ * @returns {boolean}
170
+ */
171
+ static #isAnchoredOrPadFallback(context) {
172
+ return (
173
+ Boolean(context?.isExactAnchoredOwner) ||
174
+ String(context?.placement?.projection?.source || '') ===
175
+ 'pad-fallback'
176
+ )
177
+ }
178
+
179
+ /**
180
+ * Checks whether a component owns an asymmetric five-pad footprint.
181
+ * @param {object} component PCB component.
182
+ * @param {object[]} pads Source PCB pads.
183
+ * @returns {boolean}
184
+ */
185
+ static #hasAsymmetricFivePads(component, pads) {
186
+ const surfacePads = AltiumScene3dPlacementRotationPolicy.#surfacePads(
187
+ component,
188
+ pads
189
+ )
190
+ if (surfacePads.length !== 5) {
191
+ return false
192
+ }
193
+
194
+ const axis =
195
+ AltiumScene3dPlacementRotationPolicy.#spread(surfacePads, 'x') >=
196
+ AltiumScene3dPlacementRotationPolicy.#spread(surfacePads, 'y')
197
+ ? 'x'
198
+ : 'y'
199
+ const values = surfacePads.map((pad) => Number(pad?.[axis] || 0))
200
+ const midpoint = (Math.min(...values) + Math.max(...values)) / 2
201
+ const lowerCount = values.filter((value) => value <= midpoint).length
202
+ const upperCount = values.length - lowerCount
203
+
204
+ return (
205
+ Math.min(lowerCount, upperCount) === 2 &&
206
+ Math.max(lowerCount, upperCount) === 3
207
+ )
208
+ }
209
+
210
+ /**
211
+ * Collects surface pads owned by one component.
212
+ * @param {object} component PCB component.
213
+ * @param {object[]} pads Source PCB pads.
214
+ * @returns {object[]}
215
+ */
216
+ static #surfacePads(component, pads) {
217
+ const componentIndex = Number(component?.componentIndex)
218
+ if (!Number.isFinite(componentIndex)) {
219
+ return []
220
+ }
221
+
222
+ const ownedPads = (Array.isArray(pads) ? pads : []).filter(
223
+ (pad) => Number(pad?.componentIndex) === componentIndex
224
+ )
225
+ const bottom =
226
+ String(component?.layer || '')
227
+ .toUpperCase()
228
+ .includes('BOTTOM') ||
229
+ String(component?.layer || '').toUpperCase() === 'BOT'
230
+ const surfacePads = ownedPads.filter((pad) =>
231
+ bottom
232
+ ? Boolean(pad?.hasBottomPasteMaskOpening)
233
+ : Boolean(pad?.hasTopPasteMaskOpening)
234
+ )
235
+
236
+ return surfacePads.length ? surfacePads : ownedPads
237
+ }
238
+
239
+ /**
240
+ * Measures pad center spread on one axis.
241
+ * @param {object[]} pads Source PCB pads.
242
+ * @param {'x' | 'y'} axis Axis key.
243
+ * @returns {number}
244
+ */
245
+ static #spread(pads, axis) {
246
+ const values = pads.map((pad) => Number(pad?.[axis] || 0))
247
+
248
+ return Math.max(...values) - Math.min(...values)
249
+ }
250
+
251
+ /**
252
+ * Checks whether a body has an intentional negative standoff within its
253
+ * own height envelope.
254
+ * @param {object | null | undefined} componentBody Source component body.
255
+ * @returns {boolean}
256
+ */
257
+ static #hasInEnvelopeNegativeStandoff(componentBody) {
258
+ const standoff = Number(
259
+ componentBody?.standoffHeightMil ?? componentBody?.dzMil
260
+ )
261
+ const overallHeight = Number(componentBody?.overallHeightMil)
262
+
263
+ return (
264
+ Number.isFinite(standoff) &&
265
+ Number.isFinite(overallHeight) &&
266
+ standoff < 0 &&
267
+ overallHeight > 0 &&
268
+ Math.abs(standoff) < overallHeight
269
+ )
270
+ }
271
+
272
+ /**
273
+ * Checks whether the source model is laid over with a right-angle tilt.
274
+ * @param {object | null | undefined} componentBody Source component body.
275
+ * @returns {boolean}
276
+ */
277
+ static #hasRightAngleModelTilt(componentBody) {
278
+ const angle = AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
279
+ Number(componentBody?.modelRotationDeg?.x || 0)
280
+ )
281
+
282
+ return angle === 90 || angle === 270
283
+ }
284
+
285
+ /**
286
+ * Counts generic edge-connector identity tokens in package metadata.
287
+ * @param {string} identityText Package metadata text.
288
+ * @returns {number}
289
+ */
290
+ static #edgeConnectorTokenCount(identityText) {
291
+ return new Set(
292
+ String(identityText || '')
293
+ .split(/[^a-zA-Z0-9]+/g)
294
+ .map((token) => token.toLowerCase())
295
+ .filter((token) => EDGE_CONNECTOR_TOKENS.has(token))
296
+ ).size
297
+ }
298
+
299
+ /**
300
+ * Checks whether a body is a generic passive package where body yaw is safe.
301
+ * @param {object | null | undefined} componentBody Source component body.
302
+ * @returns {boolean}
303
+ */
304
+ static #isGenericPassiveBody(componentBody) {
305
+ return PASSIVE_BODY_PATTERN.test(
306
+ [componentBody?.identifier, componentBody?.name].join(' ')
307
+ )
308
+ }
309
+
310
+ /**
311
+ * Builds package metadata text for generic package-family checks.
312
+ * @param {object} component PCB component.
313
+ * @param {object | null | undefined} componentBody Source component body.
314
+ * @returns {string}
315
+ */
316
+ static #packageIdentityText(component, componentBody) {
317
+ const parameterValues = Object.values(component?.parameters || {})
318
+ .map((value) => String(value || ''))
319
+ .join(' ')
320
+
321
+ return [
322
+ component?.designator,
323
+ component?.pattern,
324
+ component?.source,
325
+ component?.modelPath,
326
+ component?.description,
327
+ component?.provenance?.footprintDescription,
328
+ parameterValues,
329
+ componentBody?.identifier,
330
+ componentBody?.name
331
+ ]
332
+ .map((value) => String(value || ''))
333
+ .join(' ')
334
+ }
335
+
336
+ /**
337
+ * Normalizes an angle into [0, 360).
338
+ * @param {number} angle Source angle.
339
+ * @returns {number}
340
+ */
341
+ static #normalizeAngle(angle) {
342
+ const normalized = Number(angle || 0) % 360
343
+
344
+ return normalized < 0 ? normalized + 360 : normalized
345
+ }
346
+
347
+ /**
348
+ * Checks whether an angle is a half-turn after normalization.
349
+ * @param {number} angle Source angle.
350
+ * @returns {boolean}
351
+ */
352
+ static #isHalfTurnAngle(angle) {
353
+ return (
354
+ AltiumScene3dPlacementRotationPolicy.#normalizeAngle(angle) === 180
355
+ )
356
+ }
357
+ }