altium-toolkit 1.1.36 → 1.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import { AltiumScene3dIdentityTokens } from './AltiumScene3dIdentityTokens.mjs'
2
+ import { AltiumScene3dAuthoredConnectorYawPolicy } from './AltiumScene3dAuthoredConnectorYawPolicy.mjs'
2
3
  import { AltiumScene3dPlacementRotationPolicy } from './AltiumScene3dPlacementRotationPolicy.mjs'
3
4
  import { AltiumScene3dRepeatedModelOwnerRepair } from './AltiumScene3dRepeatedModelOwnerRepair.mjs'
5
+ import { AltiumScene3dBottomSourceHalfTurnPolicy } from './AltiumScene3dBottomSourceHalfTurnPolicy.mjs'
4
6
 
5
7
  /**
6
8
  * Repairs Altium explicit 3D body placements after toolkit scene conversion.
@@ -10,6 +12,8 @@ export class AltiumScene3dExternalPlacementAdapter {
10
12
  static #NEAR_ANCHOR_TOLERANCE_MIL = 20
11
13
  static #FAR_OWNER_DISTANCE_MIL = 100
12
14
  static #MODEL_ANCHOR_NEAR_OWNER_TOLERANCE_MIL = 35
15
+ static #MODEL_ANCHOR_PAD_PROXIMITY_TOLERANCE_MIL = 10
16
+ static #MODEL_ANCHOR_METADATA_PROXIMITY_MIL = 350
13
17
  static #DEFAULT_BOARD_THICKNESS_MIL = 63
14
18
  static #PASSIVE_BODY_PATTERN =
15
19
  /(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|crystal|xtal|lqw|lqg)(?:$|[^a-z0-9])/i
@@ -111,6 +115,15 @@ export class AltiumScene3dExternalPlacementAdapter {
111
115
  currentComponent
112
116
  )
113
117
  : Number.POSITIVE_INFINITY
118
+ const currentHasOwnedDrilledPadAnchor = currentComponent
119
+ ? AltiumScene3dExternalPlacementAdapter.#distanceToNearestOwnedPad(
120
+ placement,
121
+ currentComponent,
122
+ pads
123
+ ) <=
124
+ AltiumScene3dExternalPlacementAdapter
125
+ .#MODEL_ANCHOR_PAD_PROXIMITY_TOLERANCE_MIL
126
+ : false
114
127
  const currentHasMetadataAffinity = currentComponent
115
128
  ? AltiumScene3dExternalPlacementAdapter.#hasMetadataAffinity(
116
129
  placement,
@@ -140,13 +153,15 @@ export class AltiumScene3dExternalPlacementAdapter {
140
153
  components,
141
154
  currentHasMetadataAffinity,
142
155
  currentHasPartCodeAffinity,
143
- componentBody
156
+ componentBody,
157
+ pads
144
158
  )
145
159
  const isExactAnchoredOwner =
146
160
  (currentComponent &&
147
161
  currentDistance <=
148
162
  AltiumScene3dExternalPlacementAdapter
149
163
  .#EXACT_ANCHOR_TOLERANCE_MIL) ||
164
+ currentHasOwnedDrilledPadAnchor ||
150
165
  (exactComponent &&
151
166
  AltiumScene3dExternalPlacementAdapter.#distanceToBody(
152
167
  placement,
@@ -156,6 +171,7 @@ export class AltiumScene3dExternalPlacementAdapter {
156
171
  .#EXACT_ANCHOR_TOLERANCE_MIL)
157
172
  const isFarCurrentOwner =
158
173
  currentComponent &&
174
+ !currentHasOwnedDrilledPadAnchor &&
159
175
  currentDistance >
160
176
  AltiumScene3dExternalPlacementAdapter.#FAR_OWNER_DISTANCE_MIL
161
177
  const metadataComponent =
@@ -228,14 +244,24 @@ export class AltiumScene3dExternalPlacementAdapter {
228
244
  : placement.modelTransform
229
245
  }
230
246
  : placement
247
+ const shouldPreserveAuthoredConnectorYaw =
248
+ AltiumScene3dAuthoredConnectorYawPolicy.shouldPreserve({
249
+ placement: nextPlacement,
250
+ component: resolvedComponent,
251
+ pads,
252
+ ownerOffsetToleranceMil:
253
+ AltiumScene3dExternalPlacementAdapter
254
+ .#MODEL_ANCHOR_NEAR_OWNER_TOLERANCE_MIL
255
+ })
231
256
  const shouldUseComponentYaw =
232
- Boolean(metadataComponent && !exactComponent) ||
233
- Boolean(
234
- !exactComponent &&
235
- isFarCurrentOwner &&
236
- currentHasMetadataAffinity &&
237
- !currentIsMechanicalOwner
238
- )
257
+ !shouldPreserveAuthoredConnectorYaw &&
258
+ (Boolean(metadataComponent && !exactComponent) ||
259
+ Boolean(
260
+ !exactComponent &&
261
+ isFarCurrentOwner &&
262
+ currentHasMetadataAffinity &&
263
+ !currentIsMechanicalOwner
264
+ ))
239
265
  const rotationContext = {
240
266
  placement: nextPlacement,
241
267
  component: resolvedComponent,
@@ -257,7 +283,11 @@ export class AltiumScene3dExternalPlacementAdapter {
257
283
  AltiumScene3dPlacementRotationPolicy.shouldCorrectYaw(
258
284
  rotationContext
259
285
  ),
260
- footprintYaw
286
+ footprintYaw,
287
+ AltiumScene3dPlacementRotationPolicy.shouldMirrorSourceZ(
288
+ rotationContext
289
+ ),
290
+ pads
261
291
  )
262
292
 
263
293
  return AltiumScene3dExternalPlacementAdapter.#withContactPadHints(
@@ -372,6 +402,41 @@ export class AltiumScene3dExternalPlacementAdapter {
372
402
  )
373
403
  }
374
404
 
405
+ /**
406
+ * Checks whether a component owns drilled or slotted pads.
407
+ * @param {object | null | undefined} component Owning component.
408
+ * @param {object[]} pads Source PCB pads.
409
+ * @returns {boolean}
410
+ */
411
+ static #componentHasThroughHolePads(component, pads) {
412
+ return AltiumScene3dExternalPlacementAdapter.#componentPads(
413
+ component,
414
+ pads
415
+ ).some((pad) =>
416
+ AltiumScene3dExternalPlacementAdapter.#hasDrilledPadOpening(pad)
417
+ )
418
+ }
419
+
420
+ /**
421
+ * Checks whether one pad contains a drilled or slotted board opening.
422
+ * @param {object} pad Source PCB pad.
423
+ * @returns {boolean}
424
+ */
425
+ static #hasDrilledPadOpening(pad) {
426
+ const holeGeometry = pad?.holeGeometry || {}
427
+
428
+ return [
429
+ pad?.holeDiameter,
430
+ pad?.drillDiameter,
431
+ pad?.holeSize,
432
+ pad?.holeSlotLength,
433
+ pad?.slotLength,
434
+ holeGeometry?.diameter,
435
+ holeGeometry?.length,
436
+ holeGeometry?.slotLength
437
+ ].some((value) => Number(value || 0) > 0)
438
+ }
439
+
375
440
  /**
376
441
  * Checks whether a pad exposes top paste and should be soldered on the
377
442
  * top face.
@@ -408,6 +473,7 @@ export class AltiumScene3dExternalPlacementAdapter {
408
473
  * @param {boolean} currentHasMetadataAffinity Whether source metadata confirms the current owner.
409
474
  * @param {boolean} currentHasPartCodeAffinity Whether a strong part code confirms the current owner.
410
475
  * @param {object | null} componentBody Source component body.
476
+ * @param {object[]} pads Source PCB pads.
411
477
  * @returns {object | null}
412
478
  */
413
479
  static #resolveAnchorComponent(
@@ -416,7 +482,8 @@ export class AltiumScene3dExternalPlacementAdapter {
416
482
  components,
417
483
  currentHasMetadataAffinity,
418
484
  currentHasPartCodeAffinity,
419
- componentBody
485
+ componentBody,
486
+ pads
420
487
  ) {
421
488
  const currentDistance = currentComponent
422
489
  ? AltiumScene3dExternalPlacementAdapter.#distanceToBody(
@@ -479,12 +546,14 @@ export class AltiumScene3dExternalPlacementAdapter {
479
546
  return AltiumScene3dExternalPlacementAdapter.#nearestModelAnchorOwner(
480
547
  placement,
481
548
  componentBody,
482
- components
549
+ components,
550
+ pads
483
551
  )
484
552
  }
485
553
 
486
- return AltiumScene3dExternalPlacementAdapter.#nearestAnchorComponent(
554
+ return AltiumScene3dExternalPlacementAdapter.#nearestCompatibleAnchorComponent(
487
555
  placement,
556
+ componentBody,
488
557
  components,
489
558
  AltiumScene3dExternalPlacementAdapter.#NEAR_ANCHOR_TOLERANCE_MIL
490
559
  )
@@ -495,22 +564,37 @@ export class AltiumScene3dExternalPlacementAdapter {
495
564
  * @param {object} placement External model placement.
496
565
  * @param {object | null} componentBody Source component body.
497
566
  * @param {object[]} components PCB components.
567
+ * @param {object[]} pads Source PCB pads.
498
568
  * @returns {object | null}
499
569
  */
500
- static #nearestModelAnchorOwner(placement, componentBody, components) {
570
+ static #nearestModelAnchorOwner(
571
+ placement,
572
+ componentBody,
573
+ components,
574
+ pads
575
+ ) {
501
576
  const candidates = components
502
577
  .map((component) => ({
503
578
  component,
504
579
  distance: AltiumScene3dExternalPlacementAdapter.#distanceToBody(
505
580
  placement,
506
581
  component
507
- )
582
+ ),
583
+ padDistance:
584
+ AltiumScene3dExternalPlacementAdapter.#distanceToNearestOwnedPad(
585
+ placement,
586
+ component,
587
+ pads
588
+ )
508
589
  }))
509
590
  .filter(
510
591
  (candidate) =>
511
- candidate.distance <=
592
+ (candidate.distance <=
512
593
  AltiumScene3dExternalPlacementAdapter
513
- .#MODEL_ANCHOR_NEAR_OWNER_TOLERANCE_MIL &&
594
+ .#MODEL_ANCHOR_NEAR_OWNER_TOLERANCE_MIL ||
595
+ candidate.padDistance <=
596
+ AltiumScene3dExternalPlacementAdapter
597
+ .#MODEL_ANCHOR_PAD_PROXIMITY_TOLERANCE_MIL) &&
514
598
  AltiumScene3dExternalPlacementAdapter.#hasModelAnchorOwnerAffinity(
515
599
  placement,
516
600
  componentBody,
@@ -522,6 +606,71 @@ export class AltiumScene3dExternalPlacementAdapter {
522
606
  return candidates[0]?.component || null
523
607
  }
524
608
 
609
+ /**
610
+ * Measures how far one model anchor sits outside pads owned by a component.
611
+ * @param {object} placement External model placement.
612
+ * @param {object} component Candidate owner component.
613
+ * @param {object[]} pads Source PCB pads.
614
+ * @returns {number}
615
+ */
616
+ static #distanceToNearestOwnedPad(placement, component, pads) {
617
+ const ownedPads = AltiumScene3dExternalPlacementAdapter.#componentPads(
618
+ component,
619
+ pads
620
+ ).filter((pad) =>
621
+ AltiumScene3dExternalPlacementAdapter.#hasDrilledPadOpening(pad)
622
+ )
623
+
624
+ if (!ownedPads.length) {
625
+ return Number.POSITIVE_INFINITY
626
+ }
627
+
628
+ return Math.min(
629
+ ...ownedPads.map((pad) => {
630
+ const distance =
631
+ AltiumScene3dExternalPlacementAdapter.#distanceBetweenPoints(
632
+ placement?.bodyPositionMil,
633
+ { x: pad?.x, y: pad?.y }
634
+ )
635
+ const radius =
636
+ AltiumScene3dExternalPlacementAdapter.#padAnchorRadiusMil(
637
+ pad
638
+ )
639
+
640
+ return radius > 0
641
+ ? Math.max(0, distance - radius)
642
+ : Number.POSITIVE_INFINITY
643
+ })
644
+ )
645
+ }
646
+
647
+ /**
648
+ * Resolves the effective XY radius around a drilled pad center.
649
+ * @param {object} pad Source PCB pad.
650
+ * @returns {number}
651
+ */
652
+ static #padAnchorRadiusMil(pad) {
653
+ const holeGeometry = pad?.holeGeometry || {}
654
+ const diameter = Math.max(
655
+ Number(pad?.sizeTopX || 0),
656
+ Number(pad?.sizeTopY || 0),
657
+ Number(pad?.sizeMidX || 0),
658
+ Number(pad?.sizeMidY || 0),
659
+ Number(pad?.sizeBottomX || 0),
660
+ Number(pad?.sizeBottomY || 0),
661
+ Number(pad?.holeDiameter || 0),
662
+ Number(pad?.drillDiameter || 0),
663
+ Number(pad?.holeSize || 0),
664
+ Number(pad?.holeSlotLength || 0),
665
+ Number(pad?.slotLength || 0),
666
+ Number(holeGeometry?.diameter || 0),
667
+ Number(holeGeometry?.length || 0),
668
+ Number(holeGeometry?.slotLength || 0)
669
+ )
670
+
671
+ return Number.isFinite(diameter) && diameter > 0 ? diameter / 2 : 0
672
+ }
673
+
525
674
  /**
526
675
  * Checks whether a resolved model-anchor fallback should be centered on
527
676
  * its nearby compatible owner.
@@ -537,10 +686,22 @@ export class AltiumScene3dExternalPlacementAdapter {
537
686
  componentBody,
538
687
  pads
539
688
  ) {
689
+ const projectionSource = String(placement?.projection?.source || '')
540
690
  if (
541
691
  !component ||
542
- String(placement?.projection?.source || '') !==
543
- 'model-anchor-fallback'
692
+ (projectionSource !== 'model-anchor-fallback' &&
693
+ projectionSource !== 'model-bounds')
694
+ ) {
695
+ return false
696
+ }
697
+
698
+ if (
699
+ AltiumScene3dExternalPlacementAdapter.#distanceToNearestOwnedPad(
700
+ placement,
701
+ component,
702
+ pads
703
+ ) <=
704
+ AltiumScene3dExternalPlacementAdapter.#EXACT_ANCHOR_TOLERANCE_MIL
544
705
  ) {
545
706
  return false
546
707
  }
@@ -562,11 +723,16 @@ export class AltiumScene3dExternalPlacementAdapter {
562
723
  }
563
724
 
564
725
  return (
565
- AltiumScene3dExternalPlacementAdapter.#hasPartCodeAffinity(
726
+ (AltiumScene3dExternalPlacementAdapter.#hasPartCodeAffinity(
566
727
  placement,
567
728
  componentBody,
568
729
  component
569
- ) &&
730
+ ) ||
731
+ AltiumScene3dExternalPlacementAdapter.#hasModelAnchorOwnerAffinity(
732
+ placement,
733
+ componentBody,
734
+ component
735
+ )) &&
570
736
  AltiumScene3dExternalPlacementAdapter.#hasModelAnchorOwnerComponentAffinity(
571
737
  component
572
738
  ) &&
@@ -722,13 +888,19 @@ export class AltiumScene3dExternalPlacementAdapter {
722
888
  placement,
723
889
  { x: offsetX, y: offsetY }
724
890
  )
891
+ const offsetZ = Number(
892
+ modelTransform?.offsetMil?.z ?? modelTransform?.dzMil ?? 0
893
+ )
894
+ const renderableZ = Number.isFinite(offsetZ) ? offsetZ : 0
725
895
 
726
896
  return {
727
897
  ...(modelTransform || {}),
898
+ dzMil: renderableZ,
728
899
  offsetMil: {
729
900
  ...(modelTransform?.offsetMil || {}),
730
901
  x: renderableOffset.x,
731
- y: renderableOffset.y
902
+ y: renderableOffset.y,
903
+ z: renderableZ
732
904
  },
733
905
  ownerAnchorOffsetMil: {
734
906
  x: offsetX,
@@ -790,6 +962,8 @@ export class AltiumScene3dExternalPlacementAdapter {
790
962
  * @param {boolean} useComponentYaw Whether component yaw should override body yaw.
791
963
  * @param {boolean} correctPinOneYaw Whether a square IC pin-one correction applies.
792
964
  * @param {number | null} footprintYaw Footprint-derived yaw when available.
965
+ * @param {boolean} mirrorSourceZ Whether the model source length should be mirrored.
966
+ * @param {object[]} pads Source PCB pads.
793
967
  * @returns {object}
794
968
  */
795
969
  static #repairRotation(
@@ -798,9 +972,11 @@ export class AltiumScene3dExternalPlacementAdapter {
798
972
  componentBody,
799
973
  useComponentYaw,
800
974
  correctPinOneYaw,
801
- footprintYaw
975
+ footprintYaw,
976
+ mirrorSourceZ,
977
+ pads
802
978
  ) {
803
- const modelTransform =
979
+ const baseModelTransform =
804
980
  AltiumScene3dExternalPlacementAdapter.#repairModelTransform(
805
981
  placement?.modelTransform,
806
982
  componentBody
@@ -835,6 +1011,28 @@ export class AltiumScene3dExternalPlacementAdapter {
835
1011
  baseRotation + 180
836
1012
  )
837
1013
  : baseRotation
1014
+ const yawAdjustedModelTransform =
1015
+ AltiumScene3dExternalPlacementAdapter.#withFootprintRelativeModelYaw(
1016
+ baseModelTransform,
1017
+ placement,
1018
+ componentBody,
1019
+ hasFootprintYaw && !isGenericPassiveBody,
1020
+ rotationDeg
1021
+ )
1022
+ const mirroredModelTransform =
1023
+ mirrorSourceZ && !isGenericPassiveBody
1024
+ ? AltiumScene3dExternalPlacementAdapter.#withMirroredSourceZScale(
1025
+ yawAdjustedModelTransform
1026
+ )
1027
+ : yawAdjustedModelTransform
1028
+ const modelTransform =
1029
+ AltiumScene3dExternalPlacementAdapter.#normalizeBottomSurfaceMountHalfTurn(
1030
+ mirroredModelTransform,
1031
+ placement,
1032
+ component,
1033
+ componentBody,
1034
+ pads
1035
+ )
838
1036
 
839
1037
  const repairedPlacement = {
840
1038
  ...placement,
@@ -854,6 +1052,143 @@ export class AltiumScene3dExternalPlacementAdapter {
854
1052
  : repairedPlacement
855
1053
  }
856
1054
 
1055
+ /**
1056
+ * Preserves authored embedded-model yaw relative to a footprint yaw
1057
+ * override by moving the signed yaw delta into the model-local transform.
1058
+ * @param {object | null | undefined} modelTransform Placement transform.
1059
+ * @param {object | null | undefined} placement External placement.
1060
+ * @param {object | null | undefined} componentBody Source component body.
1061
+ * @param {boolean} enabled Whether footprint yaw overrode body yaw.
1062
+ * @param {number} placementYawDeg Final board-facing placement yaw.
1063
+ * @returns {object | null | undefined}
1064
+ */
1065
+ static #withFootprintRelativeModelYaw(
1066
+ modelTransform,
1067
+ placement,
1068
+ componentBody,
1069
+ enabled,
1070
+ placementYawDeg
1071
+ ) {
1072
+ const sourceYaw = Number(
1073
+ componentBody?.modelRotationDeg?.z ?? placement?.rotationDeg
1074
+ )
1075
+ if (!enabled || !Number.isFinite(sourceYaw)) {
1076
+ return modelTransform
1077
+ }
1078
+
1079
+ const rotationDeg = modelTransform?.rotationDeg || {}
1080
+ const localYaw =
1081
+ AltiumScene3dExternalPlacementAdapter.#resolveFootprintRelativeModelYaw(
1082
+ placement,
1083
+ sourceYaw,
1084
+ placementYawDeg
1085
+ )
1086
+
1087
+ return {
1088
+ ...(modelTransform || {}),
1089
+ rotationDeg: {
1090
+ ...rotationDeg,
1091
+ z: localYaw
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ /**
1097
+ * Resolves model-local yaw after a footprint yaw override. Model-bounds
1098
+ * placements retain full source geometry, so their source yaw delta uses
1099
+ * the opposite sign from pad-fallback bodies that were synthesized from
1100
+ * footprint pads.
1101
+ * @param {object | null | undefined} placement External placement.
1102
+ * @param {number} sourceYaw Source STEP yaw.
1103
+ * @param {number} placementYawDeg Board-facing placement yaw.
1104
+ * @returns {number}
1105
+ */
1106
+ static #resolveFootprintRelativeModelYaw(
1107
+ placement,
1108
+ sourceYaw,
1109
+ placementYawDeg
1110
+ ) {
1111
+ const projectionSource = String(
1112
+ placement?.projection?.source || ''
1113
+ ).toLowerCase()
1114
+ const delta =
1115
+ projectionSource === 'model-bounds'
1116
+ ? sourceYaw - Number(placementYawDeg || 0)
1117
+ : Number(placementYawDeg || 0) - sourceYaw
1118
+
1119
+ return AltiumScene3dExternalPlacementAdapter.#normalizeSignedAngle(
1120
+ delta
1121
+ )
1122
+ }
1123
+
1124
+ /**
1125
+ * Mirrors a source model along its local Z axis while preserving any
1126
+ * existing caller-provided scale on the other axes.
1127
+ * @param {object | null | undefined} modelTransform Placement transform.
1128
+ * @returns {object}
1129
+ */
1130
+ static #withMirroredSourceZScale(modelTransform) {
1131
+ const scale = modelTransform?.scale || {}
1132
+ const zScale = Number(scale.z ?? 1)
1133
+ const mirroredZScale =
1134
+ Number.isFinite(zScale) && zScale !== 0 ? -Math.abs(zScale) : -1
1135
+
1136
+ return {
1137
+ ...(modelTransform || {}),
1138
+ scale: {
1139
+ ...scale,
1140
+ z: mirroredZScale
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ /**
1146
+ * Clears bottom-side surface-mount half-turns that the mount rig already
1147
+ * supplies through its underside mirror.
1148
+ * @param {object | null | undefined} modelTransform Placement transform.
1149
+ * @param {object} placement External placement.
1150
+ * @param {object | null | undefined} component Owning component.
1151
+ * @param {object | null | undefined} componentBody Source component body.
1152
+ * @param {object[]} pads Source PCB pads.
1153
+ * @returns {object | null | undefined}
1154
+ */
1155
+ static #normalizeBottomSurfaceMountHalfTurn(
1156
+ modelTransform,
1157
+ placement,
1158
+ component,
1159
+ componentBody,
1160
+ pads
1161
+ ) {
1162
+ const rotation = modelTransform?.rotationDeg || {}
1163
+ if (
1164
+ String(placement?.mountSide || '').toLowerCase() !== 'bottom' ||
1165
+ !component ||
1166
+ AltiumScene3dExternalPlacementAdapter.#normalizeAngle(
1167
+ rotation.x
1168
+ ) !== 180 ||
1169
+ AltiumScene3dBottomSourceHalfTurnPolicy.shouldPreserve({
1170
+ component,
1171
+ componentBody,
1172
+ placement,
1173
+ modelTransform
1174
+ }) ||
1175
+ AltiumScene3dExternalPlacementAdapter.#componentHasThroughHolePads(
1176
+ component,
1177
+ pads
1178
+ )
1179
+ ) {
1180
+ return modelTransform
1181
+ }
1182
+
1183
+ return {
1184
+ ...(modelTransform || {}),
1185
+ rotationDeg: {
1186
+ ...rotation,
1187
+ x: 0
1188
+ }
1189
+ }
1190
+ }
1191
+
857
1192
  /**
858
1193
  * Finds the nearest component whose anchor is effectively the body anchor.
859
1194
  * @param {object} placement External model placement.
@@ -874,6 +1209,107 @@ export class AltiumScene3dExternalPlacementAdapter {
874
1209
  return candidates[0]?.component || null
875
1210
  }
876
1211
 
1212
+ /**
1213
+ * Finds the nearest component anchor that is compatible with a weak body
1214
+ * fallback assignment.
1215
+ * @param {object} placement External model placement.
1216
+ * @param {object | null} componentBody Source component body.
1217
+ * @param {object[]} components PCB components.
1218
+ * @param {number} toleranceMil Maximum XY body-to-component distance.
1219
+ * @returns {object | null}
1220
+ */
1221
+ static #nearestCompatibleAnchorComponent(
1222
+ placement,
1223
+ componentBody,
1224
+ components,
1225
+ toleranceMil
1226
+ ) {
1227
+ const candidates = components
1228
+ .map((component) => ({
1229
+ component,
1230
+ distance: AltiumScene3dExternalPlacementAdapter.#distanceToBody(
1231
+ placement,
1232
+ component
1233
+ )
1234
+ }))
1235
+ .filter(
1236
+ (candidate) =>
1237
+ candidate.distance <= toleranceMil &&
1238
+ AltiumScene3dExternalPlacementAdapter.#canUseNearAnchorComponent(
1239
+ placement,
1240
+ componentBody,
1241
+ candidate.component
1242
+ )
1243
+ )
1244
+ .sort((left, right) => left.distance - right.distance)
1245
+
1246
+ return candidates[0]?.component || null
1247
+ }
1248
+
1249
+ /**
1250
+ * Checks whether a near-anchor owner is strong enough for a weak fallback.
1251
+ * @param {object} placement External model placement.
1252
+ * @param {object | null} componentBody Source component body.
1253
+ * @param {object} component PCB component.
1254
+ * @returns {boolean}
1255
+ */
1256
+ static #canUseNearAnchorComponent(placement, componentBody, component) {
1257
+ if (
1258
+ !AltiumScene3dExternalPlacementAdapter.#hasPlacementComponentSideConflict(
1259
+ placement,
1260
+ component
1261
+ )
1262
+ ) {
1263
+ return true
1264
+ }
1265
+
1266
+ return (
1267
+ AltiumScene3dExternalPlacementAdapter.#hasMetadataAffinity(
1268
+ placement,
1269
+ componentBody,
1270
+ component
1271
+ ) ||
1272
+ AltiumScene3dExternalPlacementAdapter.#hasPartCodeAffinity(
1273
+ placement,
1274
+ componentBody,
1275
+ component
1276
+ )
1277
+ )
1278
+ }
1279
+
1280
+ /**
1281
+ * Checks whether a placement and candidate owner are on opposite board
1282
+ * faces.
1283
+ * @param {object} placement External model placement.
1284
+ * @param {object} component PCB component.
1285
+ * @returns {boolean}
1286
+ */
1287
+ static #hasPlacementComponentSideConflict(placement, component) {
1288
+ const placementSide =
1289
+ AltiumScene3dExternalPlacementAdapter.#normalizeMountSide(
1290
+ placement?.mountSide
1291
+ )
1292
+ const componentSide =
1293
+ AltiumScene3dExternalPlacementAdapter.#resolveComponentMountSide(
1294
+ component
1295
+ )
1296
+
1297
+ return Boolean(
1298
+ placementSide && componentSide && placementSide !== componentSide
1299
+ )
1300
+ }
1301
+
1302
+ /**
1303
+ * Normalizes external placement mount-side labels.
1304
+ * @param {unknown} value Source mount-side value.
1305
+ * @returns {'top' | 'bottom' | null}
1306
+ */
1307
+ static #normalizeMountSide(value) {
1308
+ const side = String(value || '').toLowerCase()
1309
+
1310
+ return side === 'top' || side === 'bottom' ? side : null
1311
+ }
1312
+
877
1313
  /**
878
1314
  * Builds package metadata text for generic package-family checks.
879
1315
  * @param {object} component PCB component.
@@ -941,9 +1377,59 @@ export class AltiumScene3dExternalPlacementAdapter {
941
1377
  right.score - left.score || left.distance - right.distance
942
1378
  )
943
1379
 
1380
+ const nearbyModelAnchorCandidate =
1381
+ AltiumScene3dExternalPlacementAdapter.#nearestModelAnchorMetadataCandidate(
1382
+ placement,
1383
+ componentBody,
1384
+ candidates
1385
+ )
1386
+ if (nearbyModelAnchorCandidate) {
1387
+ return nearbyModelAnchorCandidate
1388
+ }
1389
+
944
1390
  return candidates[0]?.component || null
945
1391
  }
946
1392
 
1393
+ /**
1394
+ * Resolves nearby model-anchor owners before stronger far metadata claims
1395
+ * steal repeated sub-bodies from their physical connector row.
1396
+ * @param {object} placement External model placement.
1397
+ * @param {object | null} componentBody Source component body.
1398
+ * @param {{ component: object, score: number, distance: number }[]} candidates Metadata candidates.
1399
+ * @returns {object | null}
1400
+ */
1401
+ static #nearestModelAnchorMetadataCandidate(
1402
+ placement,
1403
+ componentBody,
1404
+ candidates
1405
+ ) {
1406
+ if (
1407
+ String(placement?.projection?.source || '') !==
1408
+ 'model-anchor-fallback'
1409
+ ) {
1410
+ return null
1411
+ }
1412
+
1413
+ const nearbyCandidates = candidates
1414
+ .filter(
1415
+ (candidate) =>
1416
+ candidate.distance <=
1417
+ AltiumScene3dExternalPlacementAdapter
1418
+ .#MODEL_ANCHOR_METADATA_PROXIMITY_MIL &&
1419
+ AltiumScene3dExternalPlacementAdapter.#hasPartCodeAffinity(
1420
+ placement,
1421
+ componentBody,
1422
+ candidate.component
1423
+ )
1424
+ )
1425
+ .sort(
1426
+ (left, right) =>
1427
+ left.distance - right.distance || right.score - left.score
1428
+ )
1429
+
1430
+ return nearbyCandidates[0]?.component || null
1431
+ }
1432
+
947
1433
  /**
948
1434
  * Checks whether a weak far owner is still supported by source metadata.
949
1435
  * @param {object} placement External model placement.
@@ -1271,6 +1757,18 @@ export class AltiumScene3dExternalPlacementAdapter {
1271
1757
  return normalized < 0 ? normalized + 360 : normalized
1272
1758
  }
1273
1759
 
1760
+ /**
1761
+ * Normalizes one angle into the compact signed range [-180, 180).
1762
+ * @param {number} angle Candidate angle.
1763
+ * @returns {number}
1764
+ */
1765
+ static #normalizeSignedAngle(angle) {
1766
+ const normalized =
1767
+ AltiumScene3dExternalPlacementAdapter.#normalizeAngle(angle)
1768
+
1769
+ return normalized >= 180 ? normalized - 360 : normalized
1770
+ }
1771
+
1274
1772
  /**
1275
1773
  * Normalizes identity strings for exact substring matching.
1276
1774
  * @param {unknown[]} values Source values.