altium-toolkit 1.1.33 → 1.1.35

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.
@@ -7,7 +7,10 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
7
7
  static #BODY_ANCHOR_TOLERANCE_MIL = 5
8
8
  static #AUTHORED_SOURCE = 'authored-body-anchor'
9
9
  static #AUTHORED_ANCHOR_IDENTITY_PATTERN =
10
- /(?:^|[^a-z0-9])(?:antenna|coax|connector|edge|header|jack|mechanical|module|mount|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
10
+ /(?:^|[^a-z0-9])(?:antenna|coax|connector|edge|header|jack|mechanical|module|mount|shield|sma|socket)(?:$|[^a-z0-9])/i
11
+ static #USB_ANCHOR_IDENTITY_PATTERN = /(?:^|[^a-z0-9])usb(?:$|[^a-z0-9])/i
12
+ static #INTEGRATED_CIRCUIT_PACKAGE_PATTERN =
13
+ /(?:^|[^a-z0-9])(?:u?qfn|v?qfn|dfn|qfp|lqfp|tqfp|bga|lga|sop|soic|ssop|tssop|msop|so[-_ ]?\d+)(?:[-_ ]?\d+)?(?:$|[^a-z0-9])/i
11
14
 
12
15
  /**
13
16
  * Marks off-anchor explicit Altium body placements so the runtime does not
@@ -149,11 +152,36 @@ export class AltiumScene3dAuthoredBodyAnchorAdapter {
149
152
  * @returns {boolean}
150
153
  */
151
154
  static #hasAuthoredAnchorIdentity(placement, component) {
152
- return AltiumScene3dAuthoredBodyAnchorAdapter.#AUTHORED_ANCHOR_IDENTITY_PATTERN.test(
155
+ const identityText =
153
156
  AltiumScene3dAuthoredBodyAnchorAdapter.#identityText(
154
157
  placement,
155
158
  component
156
159
  )
160
+
161
+ return (
162
+ AltiumScene3dAuthoredBodyAnchorAdapter.#AUTHORED_ANCHOR_IDENTITY_PATTERN.test(
163
+ identityText
164
+ ) ||
165
+ AltiumScene3dAuthoredBodyAnchorAdapter.#hasUsbHardwareAnchorIdentity(
166
+ identityText
167
+ )
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Allows USB connector-like anchors without treating USB interface ICs as
173
+ * authored mechanical anchors.
174
+ * @param {string} identityText Searchable package identity.
175
+ * @returns {boolean}
176
+ */
177
+ static #hasUsbHardwareAnchorIdentity(identityText) {
178
+ return (
179
+ AltiumScene3dAuthoredBodyAnchorAdapter.#USB_ANCHOR_IDENTITY_PATTERN.test(
180
+ identityText
181
+ ) &&
182
+ !AltiumScene3dAuthoredBodyAnchorAdapter.#INTEGRATED_CIRCUIT_PACKAGE_PATTERN.test(
183
+ identityText
184
+ )
157
185
  )
158
186
  }
159
187
 
@@ -752,8 +752,12 @@ export class AltiumScene3dExternalPlacementAdapter {
752
752
  180
753
753
  const cos = Math.cos(rotationRad)
754
754
  const sin = Math.sin(rotationRad)
755
- const x = Number(offset?.x || 0) * cos - Number(offset?.y || 0) * sin
756
- const y = Number(offset?.x || 0) * sin + Number(offset?.y || 0) * cos
755
+ const sourceY =
756
+ AltiumScene3dExternalPlacementAdapter.#isBottomPlacement(placement)
757
+ ? -Number(offset?.y || 0)
758
+ : Number(offset?.y || 0)
759
+ const x = Number(offset?.x || 0) * cos - sourceY * sin
760
+ const y = Number(offset?.x || 0) * sin + sourceY * cos
757
761
 
758
762
  return {
759
763
  x: Math.abs(x) < Number.EPSILON ? 0 : Number(x.toFixed(10)),
@@ -1,3 +1,5 @@
1
+ import { AltiumScene3dTwoRowFootprintDetector } from './AltiumScene3dTwoRowFootprintDetector.mjs'
2
+
1
3
  const PASSIVE_BODY_PATTERN =
2
4
  /(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|crystal|xtal|lqw|lqg)(?:$|[^a-z0-9])/i
3
5
  const CHIP_PASSIVE_BODY_PATTERN =
@@ -6,6 +8,8 @@ const LOCAL_Y_CHIP_PASSIVE_MODEL_PATTERN =
6
8
  /(?:^|[^a-z0-9])local[-_ ]?y(?:$|[^a-z0-9])/i
7
9
  const PIN_ONE_CORNER_PACKAGE_PATTERN =
8
10
  /(?:^|[^a-z0-9])(?:[avw]?qfn|vfqfn|wqfn|v?qfp|lqfp|tqfp|pqfp|mqfp)(?:[0-9]+)?(?:$|[^a-z0-9])/i
11
+ const TWO_ROW_PIN_ONE_PACKAGE_PATTERN =
12
+ /(?:^|[^a-z0-9])(?:msop|qsop|soic|sop|ssop|tssop|tsop|vsop)(?:[-_ ]?\d+)?(?:$|[^a-z0-9])/i
9
13
  const FIVE_LEAD_SOT_PATTERN =
10
14
  /(?:^|[^a-z0-9])sot[-_ ]?(?:23[-_ ]?5|25|5)(?:$|[^a-z0-9])/i
11
15
  const THREE_LEAD_SOT23_PATTERN =
@@ -39,6 +43,9 @@ export class AltiumScene3dPlacementRotationPolicy {
39
43
  AltiumScene3dPlacementRotationPolicy.#needsSquarePinOneCorrection(
40
44
  context
41
45
  ) ||
46
+ AltiumScene3dPlacementRotationPolicy.#needsTwoRowPinOneCorrection(
47
+ context
48
+ ) ||
42
49
  AltiumScene3dPlacementRotationPolicy.#needsFiveLeadSotCorrection(
43
50
  context
44
51
  ) ||
@@ -211,6 +218,43 @@ export class AltiumScene3dPlacementRotationPolicy {
211
218
  )
212
219
  }
213
220
 
221
+ /**
222
+ * Detects two-row IC packages whose embedded source frame puts the
223
+ * package pin-one corner opposite the footprint convention.
224
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[] }} context Rotation context.
225
+ * @returns {boolean}
226
+ */
227
+ static #needsTwoRowPinOneCorrection(context) {
228
+ const { placement, component, componentBody, pads } = context || {}
229
+ if (
230
+ !AltiumScene3dPlacementRotationPolicy.#isPadFallback(placement) ||
231
+ !component ||
232
+ String(placement?.mountSide || '').toLowerCase() !== 'top' ||
233
+ AltiumScene3dPlacementRotationPolicy.#isGenericPassiveBody(
234
+ componentBody
235
+ ) ||
236
+ !AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
237
+ componentBody
238
+ )
239
+ ) {
240
+ return false
241
+ }
242
+
243
+ const identityText =
244
+ AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
245
+ component,
246
+ componentBody
247
+ )
248
+ if (!TWO_ROW_PIN_ONE_PACKAGE_PATTERN.test(identityText)) {
249
+ return false
250
+ }
251
+
252
+ return AltiumScene3dTwoRowFootprintDetector.hasTwoRowSurfaceFootprint(
253
+ component,
254
+ pads
255
+ )
256
+ }
257
+
214
258
  /**
215
259
  * Checks whether a source package yaw already matches the footprint and
216
260
  * does not need another pin-one half-turn.
@@ -1,3 +1,5 @@
1
+ import { PcbScene3dPackageDimensionResolver } from './PcbScene3dPackageDimensionResolver.mjs'
2
+
1
3
  const CONNECTOR_TOKENS = new Set([
2
4
  'antenna',
3
5
  'coax',
@@ -14,9 +16,14 @@ const TIMING_PACKAGE_PATTERN =
14
16
  /(?:^|[^a-z0-9])(?:clock|crystal|osc|oscillator|resonator|tcxo|txco|xtal)(?:$|[^a-z0-9])/i
15
17
  const TIMING_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
16
18
  const AUTHORED_ANCHOR_IDENTITY_PATTERN =
17
- /(?:^|[^a-z0-9])(?:antenna|coax|connector|edge|header|jack|mechanical|module|mount|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
19
+ /(?:^|[^a-z0-9])(?:antenna|coax|connector|edge|header|jack|mechanical|module|mount|shield|sma|socket)(?:$|[^a-z0-9])/i
18
20
  const PAD_FALLBACK_AUTHORED_ANCHOR_PATTERN =
19
- /(?:^|[^a-z0-9])(?:antenna|coax|conn|connector|edge|flex|fpc|frame|hardware|header|jack|mechanical|module|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
21
+ /(?:^|[^a-z0-9])(?:antenna|coax|conn|connector|edge|flex|fpc|frame|hardware|header|jack|mechanical|module|shield|sma|socket)(?:$|[^a-z0-9])/i
22
+ const USB_ANCHOR_IDENTITY_PATTERN = /(?:^|[^a-z0-9])usb(?:$|[^a-z0-9])/i
23
+ const INTEGRATED_CIRCUIT_PACKAGE_PATTERN =
24
+ /(?:^|[^a-z0-9])(?:u?qfn|v?qfn|dfn|qfp|lqfp|tqfp|bga|lga|sop|soic|ssop|tssop|msop|so[-_ ]?\d+)(?:[-_ ]?\d+)?(?:$|[^a-z0-9])/i
25
+ const SHIELD_COVER_TOKEN_PATTERN =
26
+ /(?:^|[^a-z0-9])(?:rf|emi|rfi|shield|cover)(?:$|[^a-z0-9])/gi
20
27
 
21
28
  /**
22
29
  * Repairs repeated Altium model-anchor bodies by matching their shared source
@@ -228,8 +235,20 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
228
235
  String(placement?.projection?.source || '').toLowerCase() ===
229
236
  'pad-fallback'
230
237
  ) {
238
+ if (
239
+ AltiumScene3dRepeatedModelOwnerRepair.#hasShieldCoverSourceOriginOffset(
240
+ placement,
241
+ component,
242
+ identityText
243
+ )
244
+ ) {
245
+ return false
246
+ }
247
+
231
248
  return (
232
- PAD_FALLBACK_AUTHORED_ANCHOR_PATTERN.test(identityText) ||
249
+ AltiumScene3dRepeatedModelOwnerRepair.#hasPadFallbackAuthoredAnchorIdentity(
250
+ identityText
251
+ ) ||
233
252
  AltiumScene3dRepeatedModelOwnerRepair.#hasCompactIcSourceOriginOffset(
234
253
  placement,
235
254
  component,
@@ -238,7 +257,149 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
238
257
  )
239
258
  }
240
259
 
241
- return AUTHORED_ANCHOR_IDENTITY_PATTERN.test(identityText)
260
+ return AltiumScene3dRepeatedModelOwnerRepair.#hasAuthoredAnchorIdentity(
261
+ identityText
262
+ )
263
+ }
264
+
265
+ /**
266
+ * Checks whether pad-fallback identity describes an authored hardware
267
+ * anchor instead of a source-origin-biased IC body.
268
+ * @param {string} identityText Searchable package identity.
269
+ * @returns {boolean}
270
+ */
271
+ static #hasPadFallbackAuthoredAnchorIdentity(identityText) {
272
+ return (
273
+ PAD_FALLBACK_AUTHORED_ANCHOR_PATTERN.test(identityText) ||
274
+ AltiumScene3dRepeatedModelOwnerRepair.#hasUsbHardwareAnchorIdentity(
275
+ identityText
276
+ )
277
+ )
278
+ }
279
+
280
+ /**
281
+ * Checks whether non-pad-fallback identity describes authored hardware.
282
+ * @param {string} identityText Searchable package identity.
283
+ * @returns {boolean}
284
+ */
285
+ static #hasAuthoredAnchorIdentity(identityText) {
286
+ return (
287
+ AUTHORED_ANCHOR_IDENTITY_PATTERN.test(identityText) ||
288
+ AltiumScene3dRepeatedModelOwnerRepair.#hasUsbHardwareAnchorIdentity(
289
+ identityText
290
+ )
291
+ )
292
+ }
293
+
294
+ /**
295
+ * Allows USB connector-like anchors without treating USB interface ICs as
296
+ * authored mechanical anchors.
297
+ * @param {string} identityText Searchable package identity.
298
+ * @returns {boolean}
299
+ */
300
+ static #hasUsbHardwareAnchorIdentity(identityText) {
301
+ return (
302
+ USB_ANCHOR_IDENTITY_PATTERN.test(identityText) &&
303
+ !INTEGRATED_CIRCUIT_PACKAGE_PATTERN.test(identityText)
304
+ )
305
+ }
306
+
307
+ /**
308
+ * Checks whether a shield cover body origin is a package corner.
309
+ * @param {object} placement Scene placement.
310
+ * @param {object} component PCB component.
311
+ * @param {string} identityText Searchable package identity.
312
+ * @returns {boolean}
313
+ */
314
+ static #hasShieldCoverSourceOriginOffset(
315
+ placement,
316
+ component,
317
+ identityText
318
+ ) {
319
+ const tokens = new Set(
320
+ [...String(identityText || '').matchAll(SHIELD_COVER_TOKEN_PATTERN)]
321
+ .map((match) => String(match[0] || '').toLowerCase())
322
+ .map((token) => token.replace(/[^a-z0-9]+/giu, ''))
323
+ )
324
+ if (
325
+ !tokens.has('cover') ||
326
+ !['rf', 'emi', 'rfi', 'shield'].some((token) => tokens.has(token))
327
+ ) {
328
+ return false
329
+ }
330
+
331
+ const size =
332
+ PcbScene3dPackageDimensionResolver.resolvePlanarSize(component)
333
+ if (!size) {
334
+ return false
335
+ }
336
+
337
+ const offset = {
338
+ x:
339
+ Number(placement?.bodyPositionMil?.x || 0) -
340
+ Number(component?.x || 0),
341
+ y:
342
+ Number(placement?.bodyPositionMil?.y || 0) -
343
+ Number(component?.y || 0)
344
+ }
345
+ if (
346
+ !Number.isFinite(offset.x) ||
347
+ !Number.isFinite(offset.y) ||
348
+ Math.hypot(offset.x, offset.y) <=
349
+ AltiumScene3dRepeatedModelOwnerRepair
350
+ .#MIN_PAD_FALLBACK_OWNER_OFFSET_MIL
351
+ ) {
352
+ return false
353
+ }
354
+
355
+ return AltiumScene3dRepeatedModelOwnerRepair.#matchesCornerOriginSize(
356
+ Math.abs(offset.x) * 2,
357
+ Math.abs(offset.y) * 2,
358
+ size
359
+ )
360
+ }
361
+
362
+ /**
363
+ * Checks whether doubled owner offset matches package dimensions.
364
+ * @param {number} offsetWidth Doubled X offset.
365
+ * @param {number} offsetDepth Doubled Y offset.
366
+ * @param {{ width: number, depth: number }} size Explicit package size.
367
+ * @returns {boolean}
368
+ */
369
+ static #matchesCornerOriginSize(offsetWidth, offsetDepth, size) {
370
+ return (
371
+ (AltiumScene3dRepeatedModelOwnerRepair.#matchesDimension(
372
+ offsetWidth,
373
+ size.width
374
+ ) &&
375
+ AltiumScene3dRepeatedModelOwnerRepair.#matchesDimension(
376
+ offsetDepth,
377
+ size.depth
378
+ )) ||
379
+ (AltiumScene3dRepeatedModelOwnerRepair.#matchesDimension(
380
+ offsetWidth,
381
+ size.depth
382
+ ) &&
383
+ AltiumScene3dRepeatedModelOwnerRepair.#matchesDimension(
384
+ offsetDepth,
385
+ size.width
386
+ ))
387
+ )
388
+ }
389
+
390
+ /**
391
+ * Checks whether two dimensions are close enough for source-origin repair.
392
+ * @param {number} actual Actual dimension.
393
+ * @param {number} expected Expected dimension.
394
+ * @returns {boolean}
395
+ */
396
+ static #matchesDimension(actual, expected) {
397
+ if (!Number.isFinite(actual) || !Number.isFinite(expected)) {
398
+ return false
399
+ }
400
+
401
+ const tolerance = Math.max(35, Math.abs(expected) * 0.08)
402
+ return Math.abs(actual - expected) <= tolerance
242
403
  }
243
404
 
244
405
  /**
@@ -557,8 +718,12 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
557
718
  180
558
719
  const cos = Math.cos(rotationRad)
559
720
  const sin = Math.sin(rotationRad)
560
- const x = Number(offset?.x || 0) * cos - Number(offset?.y || 0) * sin
561
- const y = Number(offset?.x || 0) * sin + Number(offset?.y || 0) * cos
721
+ const sourceY =
722
+ AltiumScene3dRepeatedModelOwnerRepair.#isBottomPlacement(placement)
723
+ ? -Number(offset?.y || 0)
724
+ : Number(offset?.y || 0)
725
+ const x = Number(offset?.x || 0) * cos - sourceY * sin
726
+ const y = Number(offset?.x || 0) * sin + sourceY * cos
562
727
  const z = Number(
563
728
  modelTransform?.offsetMil?.z ?? modelTransform?.dzMil ?? 0
564
729
  )
@@ -7,6 +7,7 @@ const TIMING_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
7
7
  */
8
8
  export class AltiumScene3dShapeStackOwnerAdapter {
9
9
  static #BASE_OWNER_TOLERANCE_MIL = 60
10
+ static #EXACT_OWNER_TOLERANCE_MIL = 1
10
11
  static #STACK_BODY_RADIUS_MIL = 220
11
12
  static #HEIGHT_TOLERANCE_MIL = 0.1
12
13
 
@@ -69,7 +70,8 @@ export class AltiumScene3dShapeStackOwnerAdapter {
69
70
  AltiumScene3dShapeStackOwnerAdapter.#repairStaticPlacements(
70
71
  sceneDescription.staticBodyPlacements,
71
72
  componentBodies,
72
- assignments
73
+ assignments,
74
+ sceneDescription.board
73
75
  )
74
76
  }
75
77
  }
@@ -146,9 +148,15 @@ export class AltiumScene3dShapeStackOwnerAdapter {
146
148
  * @param {object[] | undefined} placements Scene static body placements.
147
149
  * @param {object[]} componentBodies Source component-body rows.
148
150
  * @param {object[]} assignments Carrier assignments.
151
+ * @param {object | undefined} board Scene board metadata.
149
152
  * @returns {object[]}
150
153
  */
151
- static #repairStaticPlacements(placements, componentBodies, assignments) {
154
+ static #repairStaticPlacements(
155
+ placements,
156
+ componentBodies,
157
+ assignments,
158
+ board
159
+ ) {
152
160
  return (Array.isArray(placements) ? placements : []).map(
153
161
  (placement) => {
154
162
  const assignment = assignments.find((candidate) =>
@@ -164,12 +172,37 @@ export class AltiumScene3dShapeStackOwnerAdapter {
164
172
  return {
165
173
  ...placement,
166
174
  designator: String(assignment.owner.designator || ''),
175
+ positionMil:
176
+ AltiumScene3dShapeStackOwnerAdapter.#withOwnerAnchor(
177
+ placement?.positionMil,
178
+ assignment.owner,
179
+ board
180
+ ),
167
181
  coLocatedVariantGroupKey: assignment.groupKey
168
182
  }
169
183
  }
170
184
  )
171
185
  }
172
186
 
187
+ /**
188
+ * Re-anchors a repaired static carrier on the owning component position.
189
+ * @param {object | undefined} position Existing render position.
190
+ * @param {{ x?: number, y?: number }} owner Owner component.
191
+ * @param {object | undefined} board Scene board metadata.
192
+ * @returns {{ x: number, y: number, z: number }}
193
+ */
194
+ static #withOwnerAnchor(position, owner, board) {
195
+ return {
196
+ ...(position || {}),
197
+ x: AltiumScene3dShapeStackOwnerAdapter.#round(
198
+ Number(owner?.x || 0) - Number(board?.centerX || 0)
199
+ ),
200
+ y: AltiumScene3dShapeStackOwnerAdapter.#round(
201
+ Number(owner?.y || 0) - Number(board?.centerY || 0)
202
+ )
203
+ }
204
+ }
205
+
173
206
  /**
174
207
  * Builds carrier-base to owner-component assignments.
175
208
  * @param {object[]} componentBodies Source component-body rows.
@@ -187,6 +220,15 @@ export class AltiumScene3dShapeStackOwnerAdapter {
187
220
  AltiumScene3dShapeStackOwnerAdapter.#groupCarrierBases(bases)
188
221
 
189
222
  return groups.flatMap((group) => {
223
+ if (
224
+ AltiumScene3dShapeStackOwnerAdapter.#hasExactNonTimingOwner(
225
+ group.anchor,
226
+ components
227
+ )
228
+ ) {
229
+ return []
230
+ }
231
+
190
232
  const owners = timingComponents
191
233
  .filter(
192
234
  (component) =>
@@ -242,6 +284,39 @@ export class AltiumScene3dShapeStackOwnerAdapter {
242
284
  })
243
285
  }
244
286
 
287
+ /**
288
+ * Checks whether one carrier base already sits on a non-timing component.
289
+ * @param {{ x?: number, y?: number }} anchor Carrier base anchor.
290
+ * @param {object[]} components Source components.
291
+ * @returns {boolean}
292
+ */
293
+ static #hasExactNonTimingOwner(anchor, components) {
294
+ const exactOwners = (
295
+ Array.isArray(components) ? components : []
296
+ ).filter(
297
+ (component) =>
298
+ AltiumScene3dShapeStackOwnerAdapter.#distance(
299
+ anchor,
300
+ component
301
+ ) <=
302
+ AltiumScene3dShapeStackOwnerAdapter.#EXACT_OWNER_TOLERANCE_MIL
303
+ )
304
+
305
+ return (
306
+ exactOwners.some(
307
+ (component) =>
308
+ !AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(
309
+ component
310
+ )
311
+ ) &&
312
+ !exactOwners.some((component) =>
313
+ AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(
314
+ component
315
+ )
316
+ )
317
+ )
318
+ }
319
+
245
320
  /**
246
321
  * Groups carrier bases that share one source anchor.
247
322
  * @param {object[]} bases Candidate carrier bases.
@@ -469,7 +544,11 @@ export class AltiumScene3dShapeStackOwnerAdapter {
469
544
  return (
470
545
  componentBodies
471
546
  .filter(
472
- (componentBody) => !usedComponentBodies.has(componentBody)
547
+ (componentBody) =>
548
+ !usedComponentBodies.has(componentBody) &&
549
+ !AltiumScene3dShapeStackOwnerAdapter.#isCarrierBase(
550
+ componentBody
551
+ )
473
552
  )
474
553
  .map((componentBody) => ({
475
554
  componentBody,
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Detects two-row surface-mount footprints from normalized PCB pads.
3
+ */
4
+ export class AltiumScene3dTwoRowFootprintDetector {
5
+ /**
6
+ * Checks whether a component owns a symmetric two-row surface footprint.
7
+ * @param {object} component PCB component.
8
+ * @param {object[]} pads Source PCB pads.
9
+ * @returns {boolean}
10
+ */
11
+ static hasTwoRowSurfaceFootprint(component, pads) {
12
+ const surfacePads = AltiumScene3dTwoRowFootprintDetector.#surfacePads(
13
+ component,
14
+ pads
15
+ )
16
+ if (surfacePads.length < 6) {
17
+ return false
18
+ }
19
+
20
+ const xSpread = AltiumScene3dTwoRowFootprintDetector.#spread(
21
+ surfacePads,
22
+ 'x'
23
+ )
24
+ const ySpread = AltiumScene3dTwoRowFootprintDetector.#spread(
25
+ surfacePads,
26
+ 'y'
27
+ )
28
+ const rowAxis = xSpread >= ySpread ? 'y' : 'x'
29
+ const lineAxis = rowAxis === 'x' ? 'y' : 'x'
30
+ const rowSpread = AltiumScene3dTwoRowFootprintDetector.#spread(
31
+ surfacePads,
32
+ rowAxis
33
+ )
34
+ const lineSpread = AltiumScene3dTwoRowFootprintDetector.#spread(
35
+ surfacePads,
36
+ lineAxis
37
+ )
38
+ if (rowSpread <= 0 || lineSpread <= 0) {
39
+ return false
40
+ }
41
+
42
+ const values = surfacePads.map((pad) => Number(pad?.[rowAxis] || 0))
43
+ const midpoint = (Math.min(...values) + Math.max(...values)) / 2
44
+ const lowerCount = values.filter((value) => value <= midpoint).length
45
+ const upperCount = values.length - lowerCount
46
+
47
+ return Math.min(lowerCount, upperCount) >= 3
48
+ }
49
+
50
+ /**
51
+ * Collects surface pads owned by one component.
52
+ * @param {object} component PCB component.
53
+ * @param {object[]} pads Source PCB pads.
54
+ * @returns {object[]}
55
+ */
56
+ static #surfacePads(component, pads) {
57
+ const componentIndex = Number(component?.componentIndex)
58
+ if (!Number.isFinite(componentIndex)) {
59
+ return []
60
+ }
61
+
62
+ const ownedPads = (Array.isArray(pads) ? pads : []).filter(
63
+ (pad) => Number(pad?.componentIndex) === componentIndex
64
+ )
65
+ const bottom =
66
+ String(component?.layer || '')
67
+ .toUpperCase()
68
+ .includes('BOTTOM') ||
69
+ String(component?.layer || '').toUpperCase() === 'BOT'
70
+ const surfacePads = ownedPads.filter((pad) =>
71
+ bottom
72
+ ? Boolean(pad?.hasBottomPasteMaskOpening)
73
+ : Boolean(pad?.hasTopPasteMaskOpening)
74
+ )
75
+
76
+ return surfacePads.length ? surfacePads : ownedPads
77
+ }
78
+
79
+ /**
80
+ * Measures pad center spread on one axis.
81
+ * @param {object[]} pads Source PCB pads.
82
+ * @param {'x' | 'y'} axis Axis key.
83
+ * @returns {number}
84
+ */
85
+ static #spread(pads, axis) {
86
+ const values = pads.map((pad) => Number(pad?.[axis] || 0))
87
+
88
+ return Math.max(...values) - Math.min(...values)
89
+ }
90
+ }