altium-toolkit 1.1.36 → 1.1.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.
@@ -1,4 +1,6 @@
1
1
  import { AltiumScene3dTwoRowFootprintDetector } from './AltiumScene3dTwoRowFootprintDetector.mjs'
2
+ import { AltiumScene3dQfnFootprintDetector } from './AltiumScene3dQfnFootprintDetector.mjs'
3
+ import { AltiumScene3dDisplayModuleYawPolicy } from './AltiumScene3dDisplayModuleYawPolicy.mjs'
2
4
 
3
5
  const PASSIVE_BODY_PATTERN =
4
6
  /(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|crystal|xtal|lqw|lqg)(?:$|[^a-z0-9])/i
@@ -7,9 +9,11 @@ const CHIP_PASSIVE_BODY_PATTERN =
7
9
  const LOCAL_Y_CHIP_PASSIVE_MODEL_PATTERN =
8
10
  /(?:^|[^a-z0-9])local[-_ ]?y(?:$|[^a-z0-9])/i
9
11
  const PIN_ONE_CORNER_PACKAGE_PATTERN =
10
- /(?:^|[^a-z0-9])(?:[avw]?qfn|vfqfn|wqfn|v?qfp|lqfp|tqfp|pqfp|mqfp)(?:[0-9]+)?(?:$|[^a-z0-9])/i
12
+ /(?:^|[^a-z0-9])(?:[auvw]?qfn|[uv]?fqfn|v?qfp|lqfp|tqfp|pqfp|mqfp)(?:[0-9]+)?(?:$|[^a-z0-9])/i
11
13
  const TWO_ROW_PIN_ONE_PACKAGE_PATTERN =
12
14
  /(?:^|[^a-z0-9])(?:msop|qsop|soic|sop|ssop|tssop|tsop|vsop)(?:[-_ ]?\d+)?(?:$|[^a-z0-9])/i
15
+ const DFN_FOOTPRINT_YAW_PATTERN =
16
+ /(?:^|[^a-z0-9])(?:[a-z0-9]*dfn)(?:[-_ ]?[a-z0-9]+)*(?:$|[^a-z0-9])/i
13
17
  const FIVE_LEAD_SOT_PATTERN =
14
18
  /(?:^|[^a-z0-9])sot[-_ ]?(?:23[-_ ]?5|25|5)(?:$|[^a-z0-9])/i
15
19
  const THREE_LEAD_SOT23_PATTERN =
@@ -28,6 +32,8 @@ const EDGE_CONNECTOR_TOKENS = new Set([
28
32
  'sma',
29
33
  'socket'
30
34
  ])
35
+ const THROUGH_HOLE_CONNECTOR_PATTERN =
36
+ /(?:^|[^a-z0-9])(?:connector|header|socket|terminal|wire[-_ ]?to[-_ ]?board)(?:$|[^a-z0-9])/i
31
37
 
32
38
  /**
33
39
  * Resolves generic Altium external-model yaw correction rules.
@@ -52,6 +58,12 @@ export class AltiumScene3dPlacementRotationPolicy {
52
58
  AltiumScene3dPlacementRotationPolicy.#needsThreeLeadSot23Correction(
53
59
  context
54
60
  ) ||
61
+ AltiumScene3dPlacementRotationPolicy.#needsDisplayModelBoundsEdgeCorrection(
62
+ context
63
+ ) ||
64
+ AltiumScene3dPlacementRotationPolicy.#needsAlignedThroughHoleConnectorCorrection(
65
+ context
66
+ ) ||
55
67
  AltiumScene3dPlacementRotationPolicy.#needsTiltedEdgeCorrection(
56
68
  context
57
69
  )
@@ -76,6 +88,40 @@ export class AltiumScene3dPlacementRotationPolicy {
76
88
  )
77
89
  }
78
90
 
91
+ /**
92
+ * Checks whether an embedded source model should be mirrored along its
93
+ * local length axis after the Altium tilt is applied.
94
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[], isExactAnchoredOwner?: boolean }} context Rotation context.
95
+ * @returns {boolean}
96
+ */
97
+ static shouldMirrorSourceZ(context) {
98
+ const { placement, component, componentBody, pads } = context || {}
99
+ if (!component) {
100
+ return false
101
+ }
102
+
103
+ const identityText =
104
+ AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
105
+ component,
106
+ componentBody
107
+ )
108
+
109
+ return (
110
+ AltiumScene3dPlacementRotationPolicy.#isPadFallback(placement) &&
111
+ AltiumScene3dPlacementRotationPolicy.#shouldUseDfnFootprintYaw(
112
+ placement,
113
+ component,
114
+ componentBody,
115
+ pads,
116
+ identityText
117
+ ) &&
118
+ AltiumScene3dPlacementRotationPolicy.#hasPowerDfnPinOneEndPattern(
119
+ component,
120
+ pads
121
+ )
122
+ )
123
+ }
124
+
79
125
  /**
80
126
  * Resolves a board-space yaw from footprint pad geometry when the model
81
127
  * source yaw cannot be trusted.
@@ -140,6 +186,38 @@ export class AltiumScene3dPlacementRotationPolicy {
140
186
  )
141
187
  }
142
188
 
189
+ if (
190
+ AltiumScene3dDisplayModuleYawPolicy.shouldUseComponentYaw({
191
+ placement,
192
+ component,
193
+ pads,
194
+ identityText
195
+ }) &&
196
+ !AltiumScene3dPlacementRotationPolicy.#sourceYawMatchesComponentYaw(
197
+ placement,
198
+ component,
199
+ componentBody
200
+ )
201
+ ) {
202
+ return AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
203
+ Number(component?.rotation || 0)
204
+ )
205
+ }
206
+
207
+ if (
208
+ AltiumScene3dPlacementRotationPolicy.#shouldUseDfnFootprintYaw(
209
+ placement,
210
+ component,
211
+ componentBody,
212
+ pads,
213
+ identityText
214
+ )
215
+ ) {
216
+ return AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
217
+ Number(component?.rotation || 0)
218
+ )
219
+ }
220
+
143
221
  return null
144
222
  }
145
223
 
@@ -150,9 +228,9 @@ export class AltiumScene3dPlacementRotationPolicy {
150
228
  * @returns {boolean}
151
229
  */
152
230
  static #needsSquarePinOneCorrection(context) {
153
- const { placement, component, componentBody } = context || {}
231
+ const { placement, component, componentBody, pads } = context || {}
154
232
  if (
155
- !AltiumScene3dPlacementRotationPolicy.#isAnchoredOrPadFallback(
233
+ !AltiumScene3dPlacementRotationPolicy.#supportsSquarePinOneProjection(
156
234
  context
157
235
  ) ||
158
236
  !component ||
@@ -172,7 +250,8 @@ export class AltiumScene3dPlacementRotationPolicy {
172
250
  ? AltiumScene3dPlacementRotationPolicy.#hasSquarePinOneFrameMismatch(
173
251
  placement,
174
252
  component,
175
- componentBody
253
+ componentBody,
254
+ pads
176
255
  )
177
256
  : false
178
257
  }
@@ -183,26 +262,77 @@ export class AltiumScene3dPlacementRotationPolicy {
183
262
  * @param {object | undefined} placement External model placement.
184
263
  * @param {object} component PCB component.
185
264
  * @param {object | null | undefined} componentBody Source component body.
265
+ * @param {object[]} pads Source PCB pads.
186
266
  * @returns {boolean}
187
267
  */
188
- static #hasSquarePinOneFrameMismatch(placement, component, componentBody) {
268
+ static #hasSquarePinOneFrameMismatch(
269
+ placement,
270
+ component,
271
+ componentBody,
272
+ pads
273
+ ) {
189
274
  const mountSide = String(placement?.mountSide || '').toLowerCase()
190
275
  if (mountSide === 'top') {
191
- return (
192
- !AltiumScene3dPlacementRotationPolicy.#isPadFallback(
193
- placement
276
+ if (
277
+ !AltiumScene3dPlacementRotationPolicy.#isPadFallback(placement)
278
+ ) {
279
+ if (
280
+ AltiumScene3dPlacementRotationPolicy.#hasEmbeddedModelBoundsSourceHalfTurn(
281
+ placement,
282
+ component
283
+ ) ||
284
+ !AltiumScene3dPlacementRotationPolicy.#sourceYawMatchesComponentYaw(
285
+ placement,
286
+ component,
287
+ componentBody
288
+ )
289
+ ) {
290
+ return true
291
+ }
292
+
293
+ return (
294
+ (AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
295
+ componentBody
296
+ ) &&
297
+ AltiumScene3dQfnFootprintDetector.hasExposedPadPerimeterSequence(
298
+ component,
299
+ pads
300
+ )) ||
301
+ AltiumScene3dPlacementRotationPolicy.#surfacePads(
302
+ component,
303
+ pads
304
+ ).length === 0
305
+ )
306
+ }
307
+ if (
308
+ AltiumScene3dPlacementRotationPolicy.#hasEmbeddedModelBoundsSourceHalfTurn(
309
+ placement,
310
+ component
194
311
  ) ||
195
312
  !AltiumScene3dPlacementRotationPolicy.#sourceYawMatchesComponentYaw(
196
313
  placement,
197
314
  component,
198
315
  componentBody
199
316
  )
317
+ ) {
318
+ return true
319
+ }
320
+
321
+ return (
322
+ AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
323
+ componentBody
324
+ ) &&
325
+ AltiumScene3dQfnFootprintDetector.hasExposedPadPerimeterSequence(
326
+ component,
327
+ pads
328
+ )
200
329
  )
201
330
  }
202
331
 
203
332
  return (
204
333
  mountSide === 'bottom' &&
205
- AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
334
+ AltiumScene3dPlacementRotationPolicy.#isPadFallback(placement) &&
335
+ AltiumScene3dPlacementRotationPolicy.#hasBottomPinOneFrame(
206
336
  componentBody
207
337
  ) &&
208
338
  AltiumScene3dPlacementRotationPolicy.#isHalfTurnAngle(
@@ -218,6 +348,18 @@ export class AltiumScene3dPlacementRotationPolicy {
218
348
  )
219
349
  }
220
350
 
351
+ /**
352
+ * Checks whether a bottom-side square package source frame needs the
353
+ * mirrored pin-one comparison path.
354
+ * @param {object | null | undefined} componentBody Source component body.
355
+ * @returns {boolean}
356
+ */
357
+ static #hasBottomPinOneFrame(componentBody) {
358
+ return AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
359
+ componentBody
360
+ )
361
+ }
362
+
221
363
  /**
222
364
  * Detects two-row IC packages whose embedded source frame puts the
223
365
  * package pin-one corner opposite the footprint convention.
@@ -255,6 +397,189 @@ export class AltiumScene3dPlacementRotationPolicy {
255
397
  )
256
398
  }
257
399
 
400
+ /**
401
+ * Checks whether a DFN-style pad-fallback body should follow footprint yaw.
402
+ * @param {object | undefined} placement External model placement.
403
+ * @param {object} component PCB component.
404
+ * @param {object | null | undefined} componentBody Source component body.
405
+ * @param {object[]} pads Source PCB pads.
406
+ * @param {string} identityText Package metadata text.
407
+ * @returns {boolean}
408
+ */
409
+ static #shouldUseDfnFootprintYaw(
410
+ placement,
411
+ component,
412
+ componentBody,
413
+ pads,
414
+ identityText
415
+ ) {
416
+ return (
417
+ String(placement?.mountSide || '').toLowerCase() === 'top' &&
418
+ AltiumScene3dPlacementRotationPolicy.#isFootprintDerivedProjection(
419
+ placement
420
+ ) &&
421
+ String(placement?.externalModel?.origin || '').toLowerCase() ===
422
+ 'embedded' &&
423
+ DFN_FOOTPRINT_YAW_PATTERN.test(identityText) &&
424
+ AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
425
+ componentBody
426
+ ) &&
427
+ !AltiumScene3dPlacementRotationPolicy.#sourceYawMatchesComponentYaw(
428
+ placement,
429
+ component,
430
+ componentBody
431
+ ) &&
432
+ AltiumScene3dTwoRowFootprintDetector.hasTwoRowSurfaceFootprint(
433
+ component,
434
+ pads
435
+ )
436
+ )
437
+ }
438
+
439
+ /**
440
+ * Checks whether placement projection came from footprint geometry or
441
+ * footprint-matched model bounds.
442
+ * @param {object | undefined} placement External placement.
443
+ * @returns {boolean}
444
+ */
445
+ static #isFootprintDerivedProjection(placement) {
446
+ return ['pad-fallback', 'model-bounds'].includes(
447
+ String(placement?.projection?.source || '').toLowerCase()
448
+ )
449
+ }
450
+
451
+ /**
452
+ * Detects two-column power DFN footprints where one side is a tied power
453
+ * row and the opposite side has a single isolated control pad at one end.
454
+ * @param {object} component PCB component.
455
+ * @param {object[]} pads Source PCB pads.
456
+ * @returns {boolean}
457
+ */
458
+ static #hasPowerDfnPinOneEndPattern(component, pads) {
459
+ const surfacePads = AltiumScene3dPlacementRotationPolicy.#surfacePads(
460
+ component,
461
+ pads
462
+ )
463
+ if (surfacePads.length !== 8) {
464
+ return false
465
+ }
466
+
467
+ const xSpread = AltiumScene3dPlacementRotationPolicy.#spread(
468
+ surfacePads,
469
+ 'x'
470
+ )
471
+ const ySpread = AltiumScene3dPlacementRotationPolicy.#spread(
472
+ surfacePads,
473
+ 'y'
474
+ )
475
+ const sideAxis = xSpread >= ySpread ? 'x' : 'y'
476
+ const endAxis = sideAxis === 'x' ? 'y' : 'x'
477
+ const sides = AltiumScene3dPlacementRotationPolicy.#splitByMidpoint(
478
+ surfacePads,
479
+ sideAxis
480
+ )
481
+ if (sides.lower.length !== 4 || sides.upper.length !== 4) {
482
+ return false
483
+ }
484
+
485
+ return (
486
+ (AltiumScene3dPlacementRotationPolicy.#hasUniformNet(sides.lower) &&
487
+ AltiumScene3dPlacementRotationPolicy.#hasThreePlusIsolatedEndNet(
488
+ sides.upper,
489
+ endAxis
490
+ )) ||
491
+ (AltiumScene3dPlacementRotationPolicy.#hasUniformNet(sides.upper) &&
492
+ AltiumScene3dPlacementRotationPolicy.#hasThreePlusIsolatedEndNet(
493
+ sides.lower,
494
+ endAxis
495
+ ))
496
+ )
497
+ }
498
+
499
+ /**
500
+ * Splits pads into two side groups around one axis midpoint.
501
+ * @param {object[]} pads Source PCB pads.
502
+ * @param {'x' | 'y'} axis Axis key.
503
+ * @returns {{ lower: object[], upper: object[] }}
504
+ */
505
+ static #splitByMidpoint(pads, axis) {
506
+ const values = pads.map((pad) => Number(pad?.[axis] || 0))
507
+ const midpoint = (Math.min(...values) + Math.max(...values)) / 2
508
+
509
+ return {
510
+ lower: pads.filter((pad) => Number(pad?.[axis] || 0) <= midpoint),
511
+ upper: pads.filter((pad) => Number(pad?.[axis] || 0) > midpoint)
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Checks whether every pad in a side group shares one routed net.
517
+ * @param {object[]} pads Source PCB pads.
518
+ * @returns {boolean}
519
+ */
520
+ static #hasUniformNet(pads) {
521
+ const netKeys = pads
522
+ .map((pad) => AltiumScene3dPlacementRotationPolicy.#netKey(pad))
523
+ .filter(Boolean)
524
+
525
+ return netKeys.length === pads.length && new Set(netKeys).size === 1
526
+ }
527
+
528
+ /**
529
+ * Checks for a side group with three tied pads plus one isolated end pad.
530
+ * @param {object[]} pads Source PCB pads.
531
+ * @param {'x' | 'y'} endAxis Axis that orders pins along a side.
532
+ * @returns {boolean}
533
+ */
534
+ static #hasThreePlusIsolatedEndNet(pads, endAxis) {
535
+ const groups = new Map()
536
+ for (const pad of pads) {
537
+ const netKey = AltiumScene3dPlacementRotationPolicy.#netKey(pad)
538
+ if (!netKey) {
539
+ return false
540
+ }
541
+
542
+ groups.set(netKey, [...(groups.get(netKey) || []), pad])
543
+ }
544
+
545
+ const groupSizes = [...groups.values()]
546
+ .map((group) => group.length)
547
+ .sort((a, b) => a - b)
548
+ if (
549
+ groupSizes.length !== 2 ||
550
+ groupSizes[0] !== 1 ||
551
+ groupSizes[1] !== 3
552
+ ) {
553
+ return false
554
+ }
555
+
556
+ const isolatedPad = [...groups.values()].find(
557
+ (group) => group.length === 1
558
+ )?.[0]
559
+ const orderedValues = pads.map((pad) => Number(pad?.[endAxis] || 0))
560
+ const isolatedValue = Number(isolatedPad?.[endAxis] || 0)
561
+
562
+ return (
563
+ isolatedValue === Math.min(...orderedValues) ||
564
+ isolatedValue === Math.max(...orderedValues)
565
+ )
566
+ }
567
+
568
+ /**
569
+ * Builds a stable routed-net key for grouping footprint pads.
570
+ * @param {object} pad Source PCB pad.
571
+ * @returns {string}
572
+ */
573
+ static #netKey(pad) {
574
+ const netName = String(pad?.netName || '').trim()
575
+ if (netName) {
576
+ return 'name:' + netName
577
+ }
578
+
579
+ const netIndex = Number(pad?.netIndex)
580
+ return Number.isFinite(netIndex) ? 'index:' + netIndex : ''
581
+ }
582
+
258
583
  /**
259
584
  * Checks whether a source package yaw already matches the footprint and
260
585
  * does not need another pin-one half-turn.
@@ -308,21 +633,86 @@ export class AltiumScene3dPlacementRotationPolicy {
308
633
  }
309
634
 
310
635
  if (
311
- AltiumScene3dPlacementRotationPolicy.#sourceYawMatchesComponentYaw(
636
+ !AltiumScene3dPlacementRotationPolicy.#hasAsymmetricFivePads(
637
+ component,
638
+ pads
639
+ )
640
+ ) {
641
+ return false
642
+ }
643
+
644
+ if (
645
+ AltiumScene3dPlacementRotationPolicy.#hasEmbeddedModelBoundsSourceHalfTurn(
646
+ placement,
647
+ component
648
+ ) ||
649
+ !AltiumScene3dPlacementRotationPolicy.#sourceYawMatchesComponentYaw(
312
650
  placement,
313
651
  component,
314
652
  componentBody
315
653
  )
316
654
  ) {
317
- return false
655
+ return true
318
656
  }
319
657
 
320
- return AltiumScene3dPlacementRotationPolicy.#hasAsymmetricFivePads(
658
+ return AltiumScene3dPlacementRotationPolicy.#hasTopQuarterTurnFiveLeadSotPinSideMismatch(
659
+ placement,
321
660
  component,
661
+ componentBody,
322
662
  pads
323
663
  )
324
664
  }
325
665
 
666
+ /**
667
+ * Checks for layerless top SOT23-5 source frames whose yaw equals the
668
+ * component yaw but whose horizontal asymmetric pad row needs a half-turn.
669
+ * @param {object | undefined} placement External model placement.
670
+ * @param {object} component PCB component.
671
+ * @param {object | null | undefined} componentBody Source component body.
672
+ * @param {object[]} pads Source PCB pads.
673
+ * @returns {boolean}
674
+ */
675
+ static #hasTopQuarterTurnFiveLeadSotPinSideMismatch(
676
+ placement,
677
+ component,
678
+ componentBody,
679
+ pads
680
+ ) {
681
+ const mountSide = String(placement?.mountSide || '').toLowerCase()
682
+ const componentYaw =
683
+ AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
684
+ Number(component?.rotation || 0)
685
+ )
686
+ const bodyYaw = AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
687
+ Number(
688
+ componentBody?.modelRotationDeg?.z ??
689
+ placement?.modelTransform?.rotationDeg?.z ??
690
+ placement?.rotationDeg ??
691
+ 0
692
+ )
693
+ )
694
+ const surfacePads = AltiumScene3dPlacementRotationPolicy.#surfacePads(
695
+ component,
696
+ pads
697
+ )
698
+
699
+ return (
700
+ mountSide === 'top' &&
701
+ AltiumScene3dPlacementRotationPolicy.#isPadFallback(placement) &&
702
+ String(componentBody?.layer || '').trim() === '' &&
703
+ String(componentBody?.sourceStream || '').includes(
704
+ 'ShapeBasedComponentBodies'
705
+ ) &&
706
+ AltiumScene3dPlacementRotationPolicy.#isQuarterTurnAngle(
707
+ componentYaw
708
+ ) &&
709
+ bodyYaw === componentYaw &&
710
+ surfacePads.length === 5 &&
711
+ AltiumScene3dPlacementRotationPolicy.#spread(surfacePads, 'y') >
712
+ AltiumScene3dPlacementRotationPolicy.#spread(surfacePads, 'x')
713
+ )
714
+ }
715
+
326
716
  /**
327
717
  * Detects top-side SOT23-3 packages whose embedded source pin side is
328
718
  * opposite the asymmetric footprint pad convention.
@@ -365,6 +755,30 @@ export class AltiumScene3dPlacementRotationPolicy {
365
755
  )
366
756
  }
367
757
 
758
+ /**
759
+ * Detects display modules whose full model-bounds source was authored from
760
+ * the connector edge but currently extends away from its pad row.
761
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[] }} context Rotation context.
762
+ * @returns {boolean}
763
+ */
764
+ static #needsDisplayModelBoundsEdgeCorrection(context) {
765
+ const { placement, component, componentBody, pads } = context || {}
766
+ if (!component) {
767
+ return false
768
+ }
769
+
770
+ return AltiumScene3dDisplayModuleYawPolicy.shouldReverseModelBoundsYaw({
771
+ placement,
772
+ component,
773
+ pads,
774
+ identityText:
775
+ AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
776
+ component,
777
+ componentBody
778
+ )
779
+ })
780
+ }
781
+
368
782
  /**
369
783
  * Detects top-side edge connectors whose tilted model frame points inward
370
784
  * unless the authored board-facing yaw is reversed.
@@ -399,6 +813,55 @@ export class AltiumScene3dPlacementRotationPolicy {
399
813
  )
400
814
  }
401
815
 
816
+ /**
817
+ * Detects top-side through-hole connectors whose embedded source frame is
818
+ * aligned numerically but board-facing backwards after source mirroring.
819
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[], isExactAnchoredOwner?: boolean }} context Rotation context.
820
+ * @returns {boolean}
821
+ */
822
+ static #needsAlignedThroughHoleConnectorCorrection(context) {
823
+ const { placement, component, componentBody, pads } = context || {}
824
+ const hasAuthoredProjection =
825
+ AltiumScene3dPlacementRotationPolicy.#isAuthoredOrAnchoredProjection(
826
+ context
827
+ ) ||
828
+ AltiumScene3dPlacementRotationPolicy.#isModelBoundsProjection(
829
+ placement
830
+ )
831
+
832
+ if (
833
+ !component ||
834
+ String(placement?.mountSide || '').toLowerCase() !== 'top' ||
835
+ String(placement?.externalModel?.origin || '').toLowerCase() !==
836
+ 'embedded' ||
837
+ !hasAuthoredProjection ||
838
+ !AltiumScene3dPlacementRotationPolicy.#hasAuthoredBodyYaw(
839
+ componentBody
840
+ ) ||
841
+ !AltiumScene3dPlacementRotationPolicy.#sourceYawMatchesComponentYaw(
842
+ placement,
843
+ component,
844
+ componentBody
845
+ )
846
+ ) {
847
+ return false
848
+ }
849
+
850
+ const identityText =
851
+ AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
852
+ component,
853
+ componentBody
854
+ )
855
+ if (!THROUGH_HOLE_CONNECTOR_PATTERN.test(identityText)) {
856
+ return false
857
+ }
858
+
859
+ return AltiumScene3dPlacementRotationPolicy.#hasThroughHoleOnlyPads(
860
+ component,
861
+ pads
862
+ )
863
+ }
864
+
402
865
  /**
403
866
  * Checks whether one placement is exact-anchored or pad-fallback projected.
404
867
  * @param {{ placement?: object, isExactAnchoredOwner?: boolean }} context Rotation context.
@@ -413,6 +876,43 @@ export class AltiumScene3dPlacementRotationPolicy {
413
876
  )
414
877
  }
415
878
 
879
+ /**
880
+ * Checks whether one projection can use square-package pin-one correction.
881
+ * @param {{ placement?: object, componentBody?: object | null, isExactAnchoredOwner?: boolean }} context Rotation context.
882
+ * @returns {boolean}
883
+ */
884
+ static #supportsSquarePinOneProjection(context) {
885
+ return (
886
+ AltiumScene3dPlacementRotationPolicy.#isAnchoredOrPadFallback(
887
+ context
888
+ ) ||
889
+ AltiumScene3dPlacementRotationPolicy.#isBottomModelBoundsProjection(
890
+ context
891
+ )
892
+ )
893
+ }
894
+
895
+ /**
896
+ * Checks whether a bottom-side embedded model-bounds projection still
897
+ * carries the source-frame signals needed for pin-one normalization.
898
+ * @param {{ placement?: object, componentBody?: object | null }} context Rotation context.
899
+ * @returns {boolean}
900
+ */
901
+ static #isBottomModelBoundsProjection(context) {
902
+ const placement = context?.placement || {}
903
+
904
+ return (
905
+ String(placement?.projection?.source || '').toLowerCase() ===
906
+ 'model-bounds' &&
907
+ String(placement?.mountSide || '').toLowerCase() === 'bottom' &&
908
+ String(placement?.externalModel?.origin || '').toLowerCase() ===
909
+ 'embedded' &&
910
+ AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
911
+ context?.componentBody
912
+ )
913
+ )
914
+ }
915
+
416
916
  /**
417
917
  * Checks whether a placement was synthesized from footprint pads.
418
918
  * @param {object | undefined} placement External model placement.
@@ -422,6 +922,70 @@ export class AltiumScene3dPlacementRotationPolicy {
422
922
  return String(placement?.projection?.source || '') === 'pad-fallback'
423
923
  }
424
924
 
925
+ /**
926
+ * Checks whether one projection keeps an authored Altium owner/body anchor.
927
+ * @param {{ placement?: object, isExactAnchoredOwner?: boolean }} context Rotation context.
928
+ * @returns {boolean}
929
+ */
930
+ static #isAuthoredOrAnchoredProjection(context) {
931
+ const projectionSource = String(
932
+ context?.placement?.projection?.source || ''
933
+ ).toLowerCase()
934
+
935
+ return (
936
+ AltiumScene3dPlacementRotationPolicy.#isAnchoredOrPadFallback(
937
+ context
938
+ ) || projectionSource === 'authored-body-anchor'
939
+ )
940
+ }
941
+
942
+ /**
943
+ * Checks whether one placement was projected from resolved model bounds.
944
+ * @param {object | undefined} placement External model placement.
945
+ * @returns {boolean}
946
+ */
947
+ static #isModelBoundsProjection(placement) {
948
+ return (
949
+ String(placement?.projection?.source || '').toLowerCase() ===
950
+ 'model-bounds'
951
+ )
952
+ }
953
+
954
+ /**
955
+ * Checks whether recovered embedded model-bounds metadata carries a source
956
+ * yaw opposite the owning footprint yaw.
957
+ * @param {object | undefined} placement External model placement.
958
+ * @param {object} component PCB component.
959
+ * @returns {boolean}
960
+ */
961
+ static #hasEmbeddedModelBoundsSourceHalfTurn(placement, component) {
962
+ if (
963
+ !AltiumScene3dPlacementRotationPolicy.#isModelBoundsProjection(
964
+ placement
965
+ ) ||
966
+ String(placement?.externalModel?.origin || '').toLowerCase() !==
967
+ 'embedded'
968
+ ) {
969
+ return false
970
+ }
971
+
972
+ const sourceYaw = Number(
973
+ placement?.externalModel?.transform?.rotationDeg?.z
974
+ )
975
+ if (!Number.isFinite(sourceYaw)) {
976
+ return false
977
+ }
978
+
979
+ const componentYaw =
980
+ AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
981
+ Number(component?.rotation || 0)
982
+ )
983
+
984
+ return AltiumScene3dPlacementRotationPolicy.#isHalfTurnAngle(
985
+ sourceYaw - componentYaw
986
+ )
987
+ }
988
+
425
989
  /**
426
990
  * Checks whether a component owns an asymmetric five-pad footprint.
427
991
  * @param {object} component PCB component.
@@ -688,6 +1252,18 @@ export class AltiumScene3dPlacementRotationPolicy {
688
1252
  )
689
1253
  ? bodyLongAxisAngle
690
1254
  : bodyLongAxisAngle - 90
1255
+ const bottomFlatTieYaw =
1256
+ AltiumScene3dPlacementRotationPolicy.#resolveBottomFlatThreeLeadSotTieYaw(
1257
+ component,
1258
+ surfacePads,
1259
+ identityText,
1260
+ componentBody,
1261
+ componentYaw,
1262
+ modelYaw
1263
+ )
1264
+ if (bottomFlatTieYaw !== null) {
1265
+ return bottomFlatTieYaw
1266
+ }
691
1267
 
692
1268
  return AltiumScene3dPlacementRotationPolicy.#nearestLineAngle(
693
1269
  componentYaw,
@@ -695,6 +1271,66 @@ export class AltiumScene3dPlacementRotationPolicy {
695
1271
  )
696
1272
  }
697
1273
 
1274
+ /**
1275
+ * Resolves the mirrored component pin side for flat bottom-side SOT23-3 source
1276
+ * models when long-axis alignment has an exact 180-degree tie.
1277
+ * @param {object} component PCB component.
1278
+ * @param {object[]} surfacePads Surface pads owned by the component.
1279
+ * @param {string} identityText Package metadata text.
1280
+ * @param {object | null | undefined} componentBody Source component body.
1281
+ * @param {number} componentYaw Component yaw.
1282
+ * @param {number} modelYaw Candidate footprint yaw.
1283
+ * @returns {number | null}
1284
+ */
1285
+ static #resolveBottomFlatThreeLeadSotTieYaw(
1286
+ component,
1287
+ surfacePads,
1288
+ identityText,
1289
+ componentBody,
1290
+ componentYaw,
1291
+ modelYaw
1292
+ ) {
1293
+ if (
1294
+ !THREE_LEAD_SOT23_PATTERN.test(identityText) ||
1295
+ surfacePads.length !== 3 ||
1296
+ !AltiumScene3dPlacementRotationPolicy.#isBottomComponent(
1297
+ component
1298
+ ) ||
1299
+ AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
1300
+ componentBody
1301
+ ) ||
1302
+ !AltiumScene3dPlacementRotationPolicy.#usesSourceXLongAxisSot(
1303
+ identityText,
1304
+ componentBody
1305
+ )
1306
+ ) {
1307
+ return null
1308
+ }
1309
+
1310
+ const normalizedModelYaw =
1311
+ AltiumScene3dPlacementRotationPolicy.#normalizeAngle(modelYaw)
1312
+ const oppositeModelYaw =
1313
+ AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
1314
+ normalizedModelYaw + 180
1315
+ )
1316
+ const modelDistance =
1317
+ AltiumScene3dPlacementRotationPolicy.#angleDistance(
1318
+ componentYaw,
1319
+ normalizedModelYaw
1320
+ )
1321
+ const oppositeDistance =
1322
+ AltiumScene3dPlacementRotationPolicy.#angleDistance(
1323
+ componentYaw,
1324
+ oppositeModelYaw
1325
+ )
1326
+
1327
+ return Math.abs(modelDistance - oppositeDistance) < 0.001
1328
+ ? AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
1329
+ componentYaw + 180
1330
+ )
1331
+ : null
1332
+ }
1333
+
698
1334
  /**
699
1335
  * Checks whether SOT pad geometry should override the authored yaw.
700
1336
  * @param {object | undefined} placement External model placement.
@@ -765,13 +1401,9 @@ export class AltiumScene3dPlacementRotationPolicy {
765
1401
  * @returns {object[]}
766
1402
  */
767
1403
  static #surfacePads(component, pads) {
768
- const componentIndex = Number(component?.componentIndex)
769
- if (!Number.isFinite(componentIndex)) {
770
- return []
771
- }
772
-
773
- const ownedPads = (Array.isArray(pads) ? pads : []).filter(
774
- (pad) => Number(pad?.componentIndex) === componentIndex
1404
+ const ownedPads = AltiumScene3dPlacementRotationPolicy.#ownedPads(
1405
+ component,
1406
+ pads
775
1407
  )
776
1408
  const bottom =
777
1409
  String(component?.layer || '')
@@ -787,6 +1419,96 @@ export class AltiumScene3dPlacementRotationPolicy {
787
1419
  return surfacePads.length ? surfacePads : ownedPads
788
1420
  }
789
1421
 
1422
+ /**
1423
+ * Checks whether one component has only directional through-hole pads.
1424
+ * @param {object} component PCB component.
1425
+ * @param {object[] | undefined} pads Source PCB pads.
1426
+ * @returns {boolean}
1427
+ */
1428
+ static #hasThroughHoleOnlyPads(component, pads) {
1429
+ const measurablePads = AltiumScene3dPlacementRotationPolicy.#ownedPads(
1430
+ component,
1431
+ pads
1432
+ ).filter((pad) =>
1433
+ AltiumScene3dPlacementRotationPolicy.#hasMeasurablePadSize(pad)
1434
+ )
1435
+ if (measurablePads.length < 2) {
1436
+ return false
1437
+ }
1438
+
1439
+ return measurablePads.every(
1440
+ (pad) =>
1441
+ AltiumScene3dPlacementRotationPolicy.#hasDrilledPadOpening(
1442
+ pad
1443
+ ) &&
1444
+ !pad?.hasTopPasteMaskOpening &&
1445
+ !pad?.hasBottomPasteMaskOpening
1446
+ )
1447
+ }
1448
+
1449
+ /**
1450
+ * Returns pads owned by one component index.
1451
+ * @param {object} component PCB component.
1452
+ * @param {object[] | undefined} pads Source PCB pads.
1453
+ * @returns {object[]}
1454
+ */
1455
+ static #ownedPads(component, pads) {
1456
+ const componentIndex = Number(component?.componentIndex)
1457
+ if (!Number.isFinite(componentIndex)) {
1458
+ return []
1459
+ }
1460
+
1461
+ return (Array.isArray(pads) ? pads : []).filter(
1462
+ (pad) => Number(pad?.componentIndex) === componentIndex
1463
+ )
1464
+ }
1465
+
1466
+ /**
1467
+ * Checks whether one pad exposes enough physical size to guide yaw.
1468
+ * @param {object} pad Source PCB pad.
1469
+ * @returns {boolean}
1470
+ */
1471
+ static #hasMeasurablePadSize(pad) {
1472
+ const width = Math.max(
1473
+ Number(pad?.sizeTopX || 0),
1474
+ Number(pad?.sizeMidX || 0),
1475
+ Number(pad?.sizeBottomX || 0)
1476
+ )
1477
+ const depth = Math.max(
1478
+ Number(pad?.sizeTopY || 0),
1479
+ Number(pad?.sizeMidY || 0),
1480
+ Number(pad?.sizeBottomY || 0)
1481
+ )
1482
+
1483
+ return width > 0 && depth > 0
1484
+ }
1485
+
1486
+ /**
1487
+ * Checks whether one pad is drilled or otherwise through-hole-like.
1488
+ * @param {object} pad Source PCB pad.
1489
+ * @returns {boolean}
1490
+ */
1491
+ static #hasDrilledPadOpening(pad) {
1492
+ return (
1493
+ Number(pad?.holeDiameter || 0) > 0 ||
1494
+ Number(pad?.holeSize || 0) > 0 ||
1495
+ Number(pad?.drillDiameter || 0) > 0 ||
1496
+ Number(pad?.holeShape || 0) > 0 ||
1497
+ Number(pad?.layerCode || 0) > 16
1498
+ )
1499
+ }
1500
+
1501
+ /**
1502
+ * Checks whether a source component is mounted on the PCB bottom.
1503
+ * @param {object | null | undefined} component PCB component.
1504
+ * @returns {boolean}
1505
+ */
1506
+ static #isBottomComponent(component) {
1507
+ const layer = String(component?.layer || '').toUpperCase()
1508
+
1509
+ return layer.includes('BOTTOM') || layer === 'BOT'
1510
+ }
1511
+
790
1512
  /**
791
1513
  * Measures pad center spread on one axis.
792
1514
  * @param {object[]} pads Source PCB pads.
@@ -833,6 +1555,15 @@ export class AltiumScene3dPlacementRotationPolicy {
833
1555
  return angle === 90 || angle === 270
834
1556
  }
835
1557
 
1558
+ /**
1559
+ * Checks whether one body row carries an explicit board-facing yaw.
1560
+ * @param {object | null | undefined} componentBody Source component body.
1561
+ * @returns {boolean}
1562
+ */
1563
+ static #hasAuthoredBodyYaw(componentBody) {
1564
+ return Number.isFinite(Number(componentBody?.modelRotationDeg?.z))
1565
+ }
1566
+
836
1567
  /**
837
1568
  * Counts generic edge-connector identity tokens in package metadata.
838
1569
  * @param {string} identityText Package metadata text.
@@ -906,6 +1637,18 @@ export class AltiumScene3dPlacementRotationPolicy {
906
1637
  )
907
1638
  }
908
1639
 
1640
+ /**
1641
+ * Checks whether an angle is a quarter-turn after normalization.
1642
+ * @param {number} angle Source angle.
1643
+ * @returns {boolean}
1644
+ */
1645
+ static #isQuarterTurnAngle(angle) {
1646
+ const normalized =
1647
+ AltiumScene3dPlacementRotationPolicy.#normalizeAngle(angle)
1648
+
1649
+ return normalized === 90 || normalized === 270
1650
+ }
1651
+
909
1652
  /**
910
1653
  * Checks whether an angle is a standard orthogonal footprint yaw.
911
1654
  * @param {number} angle Source angle.