altium-toolkit 1.1.35 → 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.
Files changed (35) hide show
  1. package/AGENTS.md +17 -2
  2. package/package.json +1 -1
  3. package/src/core/altium/PrintableTextDecoder.mjs +20 -1
  4. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
  5. package/src/core/altium/SchematicTextParser.mjs +18 -253
  6. package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
  7. package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
  8. package/src/parser.mjs +1 -0
  9. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +245 -12
  10. package/src/ui/AltiumScene3dAuthoredConnectorYawPolicy.mjs +176 -0
  11. package/src/ui/AltiumScene3dBottomSourceHalfTurnPolicy.mjs +93 -0
  12. package/src/ui/AltiumScene3dDisplayModuleYawPolicy.mjs +284 -0
  13. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +521 -23
  14. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +766 -19
  15. package/src/ui/AltiumScene3dQfnFootprintDetector.mjs +307 -0
  16. package/src/ui/AltiumScene3dRepeatedFullFootprintBodyCollapse.mjs +518 -0
  17. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +121 -17
  18. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +286 -135
  19. package/src/ui/AltiumScene3dShapeStackOwnerConflictPolicy.mjs +299 -0
  20. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +22 -0
  21. package/src/ui/PcbScene3dBuilder.mjs +613 -117
  22. package/src/ui/PcbScene3dModelRegistry.mjs +222 -10
  23. package/src/ui/PcbScene3dPackages.mjs +16 -1
  24. package/src/ui/PcbScene3dPlacementSideResolver.mjs +262 -22
  25. package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +984 -0
  26. package/src/ui/PcbScene3dStaticBodyPadOwnerPromotion.mjs +378 -0
  27. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +169 -23
  28. package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
  29. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +7 -1
  30. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +199 -2
  31. package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +439 -0
  32. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +23 -2
  33. package/src/ui/SchematicColorResolver.mjs +44 -0
  34. package/src/ui/SchematicLineColorResolver.mjs +1 -1
  35. package/src/ui/SchematicShapeRenderer.mjs +13 -13
@@ -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.
@@ -354,6 +744,10 @@ export class AltiumScene3dPlacementRotationPolicy {
354
744
  placement,
355
745
  component,
356
746
  componentBody
747
+ ) &&
748
+ !AltiumScene3dPlacementRotationPolicy.#hasEmbeddedModelBoundsSourceHalfTurn(
749
+ placement,
750
+ component
357
751
  )
358
752
  ) {
359
753
  return false
@@ -365,6 +759,30 @@ export class AltiumScene3dPlacementRotationPolicy {
365
759
  )
366
760
  }
367
761
 
762
+ /**
763
+ * Detects display modules whose full model-bounds source was authored from
764
+ * the connector edge but currently extends away from its pad row.
765
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[] }} context Rotation context.
766
+ * @returns {boolean}
767
+ */
768
+ static #needsDisplayModelBoundsEdgeCorrection(context) {
769
+ const { placement, component, componentBody, pads } = context || {}
770
+ if (!component) {
771
+ return false
772
+ }
773
+
774
+ return AltiumScene3dDisplayModuleYawPolicy.shouldReverseModelBoundsYaw({
775
+ placement,
776
+ component,
777
+ pads,
778
+ identityText:
779
+ AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
780
+ component,
781
+ componentBody
782
+ )
783
+ })
784
+ }
785
+
368
786
  /**
369
787
  * Detects top-side edge connectors whose tilted model frame points inward
370
788
  * unless the authored board-facing yaw is reversed.
@@ -399,6 +817,55 @@ export class AltiumScene3dPlacementRotationPolicy {
399
817
  )
400
818
  }
401
819
 
820
+ /**
821
+ * Detects top-side through-hole connectors whose embedded source frame is
822
+ * aligned numerically but board-facing backwards after source mirroring.
823
+ * @param {{ placement?: object, component?: object | null, componentBody?: object | null, pads?: object[], isExactAnchoredOwner?: boolean }} context Rotation context.
824
+ * @returns {boolean}
825
+ */
826
+ static #needsAlignedThroughHoleConnectorCorrection(context) {
827
+ const { placement, component, componentBody, pads } = context || {}
828
+ const hasAuthoredProjection =
829
+ AltiumScene3dPlacementRotationPolicy.#isAuthoredOrAnchoredProjection(
830
+ context
831
+ ) ||
832
+ AltiumScene3dPlacementRotationPolicy.#isModelBoundsProjection(
833
+ placement
834
+ )
835
+
836
+ if (
837
+ !component ||
838
+ String(placement?.mountSide || '').toLowerCase() !== 'top' ||
839
+ String(placement?.externalModel?.origin || '').toLowerCase() !==
840
+ 'embedded' ||
841
+ !hasAuthoredProjection ||
842
+ !AltiumScene3dPlacementRotationPolicy.#hasAuthoredBodyYaw(
843
+ componentBody
844
+ ) ||
845
+ !AltiumScene3dPlacementRotationPolicy.#sourceYawMatchesComponentYaw(
846
+ placement,
847
+ component,
848
+ componentBody
849
+ )
850
+ ) {
851
+ return false
852
+ }
853
+
854
+ const identityText =
855
+ AltiumScene3dPlacementRotationPolicy.#packageIdentityText(
856
+ component,
857
+ componentBody
858
+ )
859
+ if (!THROUGH_HOLE_CONNECTOR_PATTERN.test(identityText)) {
860
+ return false
861
+ }
862
+
863
+ return AltiumScene3dPlacementRotationPolicy.#hasThroughHoleOnlyPads(
864
+ component,
865
+ pads
866
+ )
867
+ }
868
+
402
869
  /**
403
870
  * Checks whether one placement is exact-anchored or pad-fallback projected.
404
871
  * @param {{ placement?: object, isExactAnchoredOwner?: boolean }} context Rotation context.
@@ -413,6 +880,43 @@ export class AltiumScene3dPlacementRotationPolicy {
413
880
  )
414
881
  }
415
882
 
883
+ /**
884
+ * Checks whether one projection can use square-package pin-one correction.
885
+ * @param {{ placement?: object, componentBody?: object | null, isExactAnchoredOwner?: boolean }} context Rotation context.
886
+ * @returns {boolean}
887
+ */
888
+ static #supportsSquarePinOneProjection(context) {
889
+ return (
890
+ AltiumScene3dPlacementRotationPolicy.#isAnchoredOrPadFallback(
891
+ context
892
+ ) ||
893
+ AltiumScene3dPlacementRotationPolicy.#isBottomModelBoundsProjection(
894
+ context
895
+ )
896
+ )
897
+ }
898
+
899
+ /**
900
+ * Checks whether a bottom-side embedded model-bounds projection still
901
+ * carries the source-frame signals needed for pin-one normalization.
902
+ * @param {{ placement?: object, componentBody?: object | null }} context Rotation context.
903
+ * @returns {boolean}
904
+ */
905
+ static #isBottomModelBoundsProjection(context) {
906
+ const placement = context?.placement || {}
907
+
908
+ return (
909
+ String(placement?.projection?.source || '').toLowerCase() ===
910
+ 'model-bounds' &&
911
+ String(placement?.mountSide || '').toLowerCase() === 'bottom' &&
912
+ String(placement?.externalModel?.origin || '').toLowerCase() ===
913
+ 'embedded' &&
914
+ AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
915
+ context?.componentBody
916
+ )
917
+ )
918
+ }
919
+
416
920
  /**
417
921
  * Checks whether a placement was synthesized from footprint pads.
418
922
  * @param {object | undefined} placement External model placement.
@@ -422,6 +926,70 @@ export class AltiumScene3dPlacementRotationPolicy {
422
926
  return String(placement?.projection?.source || '') === 'pad-fallback'
423
927
  }
424
928
 
929
+ /**
930
+ * Checks whether one projection keeps an authored Altium owner/body anchor.
931
+ * @param {{ placement?: object, isExactAnchoredOwner?: boolean }} context Rotation context.
932
+ * @returns {boolean}
933
+ */
934
+ static #isAuthoredOrAnchoredProjection(context) {
935
+ const projectionSource = String(
936
+ context?.placement?.projection?.source || ''
937
+ ).toLowerCase()
938
+
939
+ return (
940
+ AltiumScene3dPlacementRotationPolicy.#isAnchoredOrPadFallback(
941
+ context
942
+ ) || projectionSource === 'authored-body-anchor'
943
+ )
944
+ }
945
+
946
+ /**
947
+ * Checks whether one placement was projected from resolved model bounds.
948
+ * @param {object | undefined} placement External model placement.
949
+ * @returns {boolean}
950
+ */
951
+ static #isModelBoundsProjection(placement) {
952
+ return (
953
+ String(placement?.projection?.source || '').toLowerCase() ===
954
+ 'model-bounds'
955
+ )
956
+ }
957
+
958
+ /**
959
+ * Checks whether recovered embedded model-bounds metadata carries a source
960
+ * yaw opposite the owning footprint yaw.
961
+ * @param {object | undefined} placement External model placement.
962
+ * @param {object} component PCB component.
963
+ * @returns {boolean}
964
+ */
965
+ static #hasEmbeddedModelBoundsSourceHalfTurn(placement, component) {
966
+ if (
967
+ !AltiumScene3dPlacementRotationPolicy.#isModelBoundsProjection(
968
+ placement
969
+ ) ||
970
+ String(placement?.externalModel?.origin || '').toLowerCase() !==
971
+ 'embedded'
972
+ ) {
973
+ return false
974
+ }
975
+
976
+ const sourceYaw = Number(
977
+ placement?.externalModel?.transform?.rotationDeg?.z
978
+ )
979
+ if (!Number.isFinite(sourceYaw)) {
980
+ return false
981
+ }
982
+
983
+ const componentYaw =
984
+ AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
985
+ Number(component?.rotation || 0)
986
+ )
987
+
988
+ return AltiumScene3dPlacementRotationPolicy.#isHalfTurnAngle(
989
+ sourceYaw - componentYaw
990
+ )
991
+ }
992
+
425
993
  /**
426
994
  * Checks whether a component owns an asymmetric five-pad footprint.
427
995
  * @param {object} component PCB component.
@@ -688,6 +1256,18 @@ export class AltiumScene3dPlacementRotationPolicy {
688
1256
  )
689
1257
  ? bodyLongAxisAngle
690
1258
  : bodyLongAxisAngle - 90
1259
+ const bottomFlatTieYaw =
1260
+ AltiumScene3dPlacementRotationPolicy.#resolveBottomFlatThreeLeadSotTieYaw(
1261
+ component,
1262
+ surfacePads,
1263
+ identityText,
1264
+ componentBody,
1265
+ componentYaw,
1266
+ modelYaw
1267
+ )
1268
+ if (bottomFlatTieYaw !== null) {
1269
+ return bottomFlatTieYaw
1270
+ }
691
1271
 
692
1272
  return AltiumScene3dPlacementRotationPolicy.#nearestLineAngle(
693
1273
  componentYaw,
@@ -695,6 +1275,66 @@ export class AltiumScene3dPlacementRotationPolicy {
695
1275
  )
696
1276
  }
697
1277
 
1278
+ /**
1279
+ * Resolves the mirrored component pin side for flat bottom-side SOT23-3 source
1280
+ * models when long-axis alignment has an exact 180-degree tie.
1281
+ * @param {object} component PCB component.
1282
+ * @param {object[]} surfacePads Surface pads owned by the component.
1283
+ * @param {string} identityText Package metadata text.
1284
+ * @param {object | null | undefined} componentBody Source component body.
1285
+ * @param {number} componentYaw Component yaw.
1286
+ * @param {number} modelYaw Candidate footprint yaw.
1287
+ * @returns {number | null}
1288
+ */
1289
+ static #resolveBottomFlatThreeLeadSotTieYaw(
1290
+ component,
1291
+ surfacePads,
1292
+ identityText,
1293
+ componentBody,
1294
+ componentYaw,
1295
+ modelYaw
1296
+ ) {
1297
+ if (
1298
+ !THREE_LEAD_SOT23_PATTERN.test(identityText) ||
1299
+ surfacePads.length !== 3 ||
1300
+ !AltiumScene3dPlacementRotationPolicy.#isBottomComponent(
1301
+ component
1302
+ ) ||
1303
+ AltiumScene3dPlacementRotationPolicy.#hasRightAngleModelTilt(
1304
+ componentBody
1305
+ ) ||
1306
+ !AltiumScene3dPlacementRotationPolicy.#usesSourceXLongAxisSot(
1307
+ identityText,
1308
+ componentBody
1309
+ )
1310
+ ) {
1311
+ return null
1312
+ }
1313
+
1314
+ const normalizedModelYaw =
1315
+ AltiumScene3dPlacementRotationPolicy.#normalizeAngle(modelYaw)
1316
+ const oppositeModelYaw =
1317
+ AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
1318
+ normalizedModelYaw + 180
1319
+ )
1320
+ const modelDistance =
1321
+ AltiumScene3dPlacementRotationPolicy.#angleDistance(
1322
+ componentYaw,
1323
+ normalizedModelYaw
1324
+ )
1325
+ const oppositeDistance =
1326
+ AltiumScene3dPlacementRotationPolicy.#angleDistance(
1327
+ componentYaw,
1328
+ oppositeModelYaw
1329
+ )
1330
+
1331
+ return Math.abs(modelDistance - oppositeDistance) < 0.001
1332
+ ? AltiumScene3dPlacementRotationPolicy.#normalizeAngle(
1333
+ componentYaw + 180
1334
+ )
1335
+ : null
1336
+ }
1337
+
698
1338
  /**
699
1339
  * Checks whether SOT pad geometry should override the authored yaw.
700
1340
  * @param {object | undefined} placement External model placement.
@@ -765,13 +1405,9 @@ export class AltiumScene3dPlacementRotationPolicy {
765
1405
  * @returns {object[]}
766
1406
  */
767
1407
  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
1408
+ const ownedPads = AltiumScene3dPlacementRotationPolicy.#ownedPads(
1409
+ component,
1410
+ pads
775
1411
  )
776
1412
  const bottom =
777
1413
  String(component?.layer || '')
@@ -787,6 +1423,96 @@ export class AltiumScene3dPlacementRotationPolicy {
787
1423
  return surfacePads.length ? surfacePads : ownedPads
788
1424
  }
789
1425
 
1426
+ /**
1427
+ * Checks whether one component has only directional through-hole pads.
1428
+ * @param {object} component PCB component.
1429
+ * @param {object[] | undefined} pads Source PCB pads.
1430
+ * @returns {boolean}
1431
+ */
1432
+ static #hasThroughHoleOnlyPads(component, pads) {
1433
+ const measurablePads = AltiumScene3dPlacementRotationPolicy.#ownedPads(
1434
+ component,
1435
+ pads
1436
+ ).filter((pad) =>
1437
+ AltiumScene3dPlacementRotationPolicy.#hasMeasurablePadSize(pad)
1438
+ )
1439
+ if (measurablePads.length < 2) {
1440
+ return false
1441
+ }
1442
+
1443
+ return measurablePads.every(
1444
+ (pad) =>
1445
+ AltiumScene3dPlacementRotationPolicy.#hasDrilledPadOpening(
1446
+ pad
1447
+ ) &&
1448
+ !pad?.hasTopPasteMaskOpening &&
1449
+ !pad?.hasBottomPasteMaskOpening
1450
+ )
1451
+ }
1452
+
1453
+ /**
1454
+ * Returns pads owned by one component index.
1455
+ * @param {object} component PCB component.
1456
+ * @param {object[] | undefined} pads Source PCB pads.
1457
+ * @returns {object[]}
1458
+ */
1459
+ static #ownedPads(component, pads) {
1460
+ const componentIndex = Number(component?.componentIndex)
1461
+ if (!Number.isFinite(componentIndex)) {
1462
+ return []
1463
+ }
1464
+
1465
+ return (Array.isArray(pads) ? pads : []).filter(
1466
+ (pad) => Number(pad?.componentIndex) === componentIndex
1467
+ )
1468
+ }
1469
+
1470
+ /**
1471
+ * Checks whether one pad exposes enough physical size to guide yaw.
1472
+ * @param {object} pad Source PCB pad.
1473
+ * @returns {boolean}
1474
+ */
1475
+ static #hasMeasurablePadSize(pad) {
1476
+ const width = Math.max(
1477
+ Number(pad?.sizeTopX || 0),
1478
+ Number(pad?.sizeMidX || 0),
1479
+ Number(pad?.sizeBottomX || 0)
1480
+ )
1481
+ const depth = Math.max(
1482
+ Number(pad?.sizeTopY || 0),
1483
+ Number(pad?.sizeMidY || 0),
1484
+ Number(pad?.sizeBottomY || 0)
1485
+ )
1486
+
1487
+ return width > 0 && depth > 0
1488
+ }
1489
+
1490
+ /**
1491
+ * Checks whether one pad is drilled or otherwise through-hole-like.
1492
+ * @param {object} pad Source PCB pad.
1493
+ * @returns {boolean}
1494
+ */
1495
+ static #hasDrilledPadOpening(pad) {
1496
+ return (
1497
+ Number(pad?.holeDiameter || 0) > 0 ||
1498
+ Number(pad?.holeSize || 0) > 0 ||
1499
+ Number(pad?.drillDiameter || 0) > 0 ||
1500
+ Number(pad?.holeShape || 0) > 0 ||
1501
+ Number(pad?.layerCode || 0) > 16
1502
+ )
1503
+ }
1504
+
1505
+ /**
1506
+ * Checks whether a source component is mounted on the PCB bottom.
1507
+ * @param {object | null | undefined} component PCB component.
1508
+ * @returns {boolean}
1509
+ */
1510
+ static #isBottomComponent(component) {
1511
+ const layer = String(component?.layer || '').toUpperCase()
1512
+
1513
+ return layer.includes('BOTTOM') || layer === 'BOT'
1514
+ }
1515
+
790
1516
  /**
791
1517
  * Measures pad center spread on one axis.
792
1518
  * @param {object[]} pads Source PCB pads.
@@ -833,6 +1559,15 @@ export class AltiumScene3dPlacementRotationPolicy {
833
1559
  return angle === 90 || angle === 270
834
1560
  }
835
1561
 
1562
+ /**
1563
+ * Checks whether one body row carries an explicit board-facing yaw.
1564
+ * @param {object | null | undefined} componentBody Source component body.
1565
+ * @returns {boolean}
1566
+ */
1567
+ static #hasAuthoredBodyYaw(componentBody) {
1568
+ return Number.isFinite(Number(componentBody?.modelRotationDeg?.z))
1569
+ }
1570
+
836
1571
  /**
837
1572
  * Counts generic edge-connector identity tokens in package metadata.
838
1573
  * @param {string} identityText Package metadata text.
@@ -906,6 +1641,18 @@ export class AltiumScene3dPlacementRotationPolicy {
906
1641
  )
907
1642
  }
908
1643
 
1644
+ /**
1645
+ * Checks whether an angle is a quarter-turn after normalization.
1646
+ * @param {number} angle Source angle.
1647
+ * @returns {boolean}
1648
+ */
1649
+ static #isQuarterTurnAngle(angle) {
1650
+ const normalized =
1651
+ AltiumScene3dPlacementRotationPolicy.#normalizeAngle(angle)
1652
+
1653
+ return normalized === 90 || normalized === 270
1654
+ }
1655
+
909
1656
  /**
910
1657
  * Checks whether an angle is a standard orthogonal footprint yaw.
911
1658
  * @param {number} angle Source angle.