altium-toolkit 1.1.26 → 1.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,919 @@
1
+ import { AltiumScene3dIdentityTokens } from './AltiumScene3dIdentityTokens.mjs'
2
+ import { AltiumScene3dPlacementRotationPolicy } from './AltiumScene3dPlacementRotationPolicy.mjs'
3
+ import { AltiumScene3dRepeatedModelOwnerRepair } from './AltiumScene3dRepeatedModelOwnerRepair.mjs'
4
+
5
+ /**
6
+ * Repairs Altium explicit 3D body placements after toolkit scene conversion.
7
+ */
8
+ export class AltiumScene3dExternalPlacementAdapter {
9
+ static #EXACT_ANCHOR_TOLERANCE_MIL = 5
10
+ static #NEAR_ANCHOR_TOLERANCE_MIL = 20
11
+ static #FAR_OWNER_DISTANCE_MIL = 100
12
+ static #DEFAULT_BOARD_THICKNESS_MIL = 63
13
+ static #PASSIVE_BODY_PATTERN =
14
+ /(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|crystal|xtal|lqw|lqg)(?:$|[^a-z0-9])/i
15
+ static #MECHANICAL_OWNER_PATTERN =
16
+ /(?:^|[^a-z0-9])(?:mech|mechanical|shield|frame|cover|hardware)(?:$|[^a-z0-9])/i
17
+
18
+ /**
19
+ * Applies exact-anchor repairs to Altium external 3D placements.
20
+ * @param {object} sceneDescription Built scene description.
21
+ * @param {object} documentModel Source document model.
22
+ * @returns {object}
23
+ */
24
+ static apply(sceneDescription, documentModel) {
25
+ if (
26
+ String(sceneDescription?.sourceFormat || '').toLowerCase() !==
27
+ 'altium' ||
28
+ !Array.isArray(sceneDescription?.externalPlacements)
29
+ ) {
30
+ return sceneDescription
31
+ }
32
+
33
+ const components = Array.isArray(documentModel?.pcb?.components)
34
+ ? documentModel.pcb.components
35
+ : []
36
+ const pads = Array.isArray(documentModel?.pcb?.pads)
37
+ ? documentModel.pcb.pads
38
+ : []
39
+ const componentBodies = Array.isArray(
40
+ documentModel?.pcb?.componentBodies
41
+ )
42
+ ? documentModel.pcb.componentBodies
43
+ : []
44
+ if (!components.length) {
45
+ return sceneDescription
46
+ }
47
+
48
+ const componentByDesignator = new Map(
49
+ components.map((component) => [
50
+ String(component?.designator || ''),
51
+ component
52
+ ])
53
+ )
54
+
55
+ const repairedScene = {
56
+ ...sceneDescription,
57
+ externalPlacements: sceneDescription.externalPlacements
58
+ .map((placement) =>
59
+ AltiumScene3dExternalPlacementAdapter.#repairPlacement(
60
+ placement,
61
+ components,
62
+ componentByDesignator,
63
+ componentBodies,
64
+ pads,
65
+ sceneDescription?.board
66
+ )
67
+ )
68
+ .filter(Boolean)
69
+ }
70
+
71
+ return AltiumScene3dRepeatedModelOwnerRepair.apply(
72
+ repairedScene,
73
+ documentModel
74
+ )
75
+ }
76
+
77
+ /** Repairs one placement when a weak name match displaced an anchor. */
78
+ static #repairPlacement(
79
+ placement,
80
+ components,
81
+ componentByDesignator,
82
+ componentBodies,
83
+ pads,
84
+ board
85
+ ) {
86
+ if (!placement?.bodyPositionMil || !placement?.positionMil) {
87
+ return placement
88
+ }
89
+ const componentBody =
90
+ AltiumScene3dExternalPlacementAdapter.#resolveComponentBody(
91
+ placement,
92
+ componentBodies
93
+ )
94
+ const currentComponent = componentByDesignator.get(
95
+ String(placement?.designator || '')
96
+ )
97
+ const currentDistance = currentComponent
98
+ ? AltiumScene3dExternalPlacementAdapter.#distanceToBody(
99
+ placement,
100
+ currentComponent
101
+ )
102
+ : Number.POSITIVE_INFINITY
103
+ const currentHasMetadataAffinity = currentComponent
104
+ ? AltiumScene3dExternalPlacementAdapter.#hasMetadataAffinity(
105
+ placement,
106
+ componentBody,
107
+ currentComponent
108
+ )
109
+ : false
110
+ const currentHasPartCodeAffinity = currentComponent
111
+ ? AltiumScene3dExternalPlacementAdapter.#hasPartCodeAffinity(
112
+ placement,
113
+ componentBody,
114
+ currentComponent
115
+ )
116
+ : false
117
+ const currentIsMechanicalOwner =
118
+ currentComponent &&
119
+ AltiumScene3dExternalPlacementAdapter.#MECHANICAL_OWNER_PATTERN.test(
120
+ AltiumScene3dExternalPlacementAdapter.#packageIdentityText(
121
+ currentComponent,
122
+ componentBody
123
+ )
124
+ )
125
+ const exactComponent =
126
+ AltiumScene3dExternalPlacementAdapter.#resolveAnchorComponent(
127
+ placement,
128
+ currentComponent,
129
+ components,
130
+ currentHasMetadataAffinity,
131
+ currentHasPartCodeAffinity,
132
+ componentBody
133
+ )
134
+ const isExactAnchoredOwner =
135
+ (currentComponent &&
136
+ currentDistance <=
137
+ AltiumScene3dExternalPlacementAdapter
138
+ .#EXACT_ANCHOR_TOLERANCE_MIL) ||
139
+ (exactComponent &&
140
+ AltiumScene3dExternalPlacementAdapter.#distanceToBody(
141
+ placement,
142
+ exactComponent
143
+ ) <=
144
+ AltiumScene3dExternalPlacementAdapter
145
+ .#EXACT_ANCHOR_TOLERANCE_MIL)
146
+ const isFarCurrentOwner =
147
+ currentComponent &&
148
+ currentDistance >
149
+ AltiumScene3dExternalPlacementAdapter.#FAR_OWNER_DISTANCE_MIL
150
+ const metadataComponent =
151
+ !exactComponent &&
152
+ (!currentComponent ||
153
+ (isFarCurrentOwner && !currentHasPartCodeAffinity))
154
+ ? AltiumScene3dExternalPlacementAdapter.#resolveMetadataComponent(
155
+ placement,
156
+ componentBody,
157
+ components
158
+ )
159
+ : null
160
+
161
+ if (
162
+ isFarCurrentOwner &&
163
+ !exactComponent &&
164
+ !metadataComponent &&
165
+ !currentHasMetadataAffinity &&
166
+ !currentIsMechanicalOwner
167
+ ) {
168
+ return null
169
+ }
170
+
171
+ const resolvedComponent =
172
+ exactComponent || metadataComponent || currentComponent
173
+ const mountSide = resolvedComponent
174
+ ? AltiumScene3dExternalPlacementAdapter.#resolveComponentMountSide(
175
+ resolvedComponent
176
+ ) || placement.mountSide
177
+ : placement.mountSide
178
+ const nextPlacement =
179
+ exactComponent || metadataComponent
180
+ ? {
181
+ ...placement,
182
+ designator: String(
183
+ resolvedComponent?.designator || placement.designator
184
+ ),
185
+ mountSide,
186
+ positionMil: {
187
+ ...placement.positionMil,
188
+ z: AltiumScene3dExternalPlacementAdapter.#resolveFaceZ(
189
+ mountSide,
190
+ board
191
+ )
192
+ }
193
+ }
194
+ : placement
195
+ const shouldUseComponentYaw =
196
+ Boolean(metadataComponent && !exactComponent) ||
197
+ Boolean(
198
+ !exactComponent &&
199
+ isFarCurrentOwner &&
200
+ currentHasMetadataAffinity &&
201
+ !currentIsMechanicalOwner
202
+ )
203
+
204
+ const repairedPlacement =
205
+ AltiumScene3dExternalPlacementAdapter.#repairRotation(
206
+ nextPlacement,
207
+ resolvedComponent,
208
+ componentBody,
209
+ shouldUseComponentYaw,
210
+ AltiumScene3dPlacementRotationPolicy.shouldCorrectYaw({
211
+ placement: nextPlacement,
212
+ component: resolvedComponent,
213
+ componentBody,
214
+ pads,
215
+ isExactAnchoredOwner
216
+ })
217
+ )
218
+
219
+ return AltiumScene3dExternalPlacementAdapter.#withContactPadHints(
220
+ repairedPlacement,
221
+ resolvedComponent,
222
+ pads,
223
+ board
224
+ )
225
+ }
226
+
227
+ /** Adds pad contact hints for mixed SMT/mechanical connector footprints. */
228
+ static #withContactPadHints(placement, component, pads, board) {
229
+ const contactPads =
230
+ AltiumScene3dExternalPlacementAdapter.#resolveContactPads(
231
+ placement,
232
+ component,
233
+ pads,
234
+ board
235
+ )
236
+ if (!contactPads.length) {
237
+ return placement
238
+ }
239
+
240
+ return {
241
+ ...placement,
242
+ modelTransform: {
243
+ ...(placement?.modelTransform || {}),
244
+ contactPadsMil: contactPads
245
+ }
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Resolves board-local SMT pad centers for mixed connector footprints.
251
+ * @param {object} placement External model placement.
252
+ * @param {object | null | undefined} component Owning component.
253
+ * @param {object[]} pads Source PCB pads.
254
+ * @param {object} board Scene board metadata.
255
+ * @returns {{ x: number, y: number, width: number, depth: number }[]}
256
+ */
257
+ static #resolveContactPads(placement, component, pads, board) {
258
+ if (
259
+ !component ||
260
+ String(placement?.mountSide || '').toLowerCase() !== 'top' ||
261
+ String(placement?.projection?.source || '') !==
262
+ 'model-anchor-fallback'
263
+ ) {
264
+ return []
265
+ }
266
+
267
+ const componentPads =
268
+ AltiumScene3dExternalPlacementAdapter.#componentPads(
269
+ component,
270
+ pads
271
+ )
272
+ const surfacePads = componentPads.filter((pad) =>
273
+ AltiumScene3dExternalPlacementAdapter.#isTopSurfacePad(pad)
274
+ )
275
+ const mechanicalPads = componentPads.filter((pad) =>
276
+ AltiumScene3dExternalPlacementAdapter.#isMechanicalAnchorPad(pad)
277
+ )
278
+ if (surfacePads.length < 2 || !mechanicalPads.length) {
279
+ return []
280
+ }
281
+
282
+ const centerX = Number(board?.centerX || 0)
283
+ const centerY = Number(board?.centerY || 0)
284
+
285
+ return surfacePads
286
+ .map((pad) => ({
287
+ x: Number(pad?.x || 0) - centerX,
288
+ y: Number(pad?.y || 0) - centerY,
289
+ width: Number(pad?.sizeTopX || pad?.sizeMidX || 0),
290
+ depth: Number(pad?.sizeTopY || pad?.sizeMidY || 0)
291
+ }))
292
+ .filter(
293
+ (pad) =>
294
+ Number.isFinite(pad.x) &&
295
+ Number.isFinite(pad.y) &&
296
+ pad.width > 0 &&
297
+ pad.depth > 0
298
+ )
299
+ }
300
+
301
+ /**
302
+ * Returns pads owned by one component index.
303
+ * @param {object} component Owning component.
304
+ * @param {object[]} pads Source PCB pads.
305
+ * @returns {object[]}
306
+ */
307
+ static #componentPads(component, pads) {
308
+ const componentIndex = Number(component?.componentIndex)
309
+ if (!Number.isFinite(componentIndex)) {
310
+ return []
311
+ }
312
+
313
+ return (Array.isArray(pads) ? pads : []).filter(
314
+ (pad) => Number(pad?.componentIndex) === componentIndex
315
+ )
316
+ }
317
+
318
+ /**
319
+ * Checks whether a pad exposes top paste and should be soldered on the
320
+ * top face.
321
+ * @param {object} pad Source PCB pad.
322
+ * @returns {boolean}
323
+ */
324
+ static #isTopSurfacePad(pad) {
325
+ return (
326
+ Boolean(pad?.hasTopPasteMaskOpening) &&
327
+ Number(pad?.sizeTopX || 0) > 0 &&
328
+ Number(pad?.sizeTopY || 0) > 0
329
+ )
330
+ }
331
+
332
+ /**
333
+ * Checks whether a pad is a non-paste mechanical lock or guide.
334
+ * @param {object} pad Source PCB pad.
335
+ * @returns {boolean}
336
+ */
337
+ static #isMechanicalAnchorPad(pad) {
338
+ return (
339
+ !pad?.hasTopPasteMaskOpening &&
340
+ (Number(pad?.holeSize || 0) > 0 ||
341
+ Number(pad?.holeShape || 0) > 0 ||
342
+ Number(pad?.layerCode || 0) > 16)
343
+ )
344
+ }
345
+
346
+ /**
347
+ * Resolves an anchor component only when the current owner is not close.
348
+ * @param {object} placement External model placement.
349
+ * @param {object | undefined} currentComponent Current matched component.
350
+ * @param {object[]} components PCB components.
351
+ * @param {boolean} currentHasMetadataAffinity Whether source metadata confirms the current owner.
352
+ * @param {boolean} currentHasPartCodeAffinity Whether a strong part code confirms the current owner.
353
+ * @param {object | null} componentBody Source component body.
354
+ * @returns {object | null}
355
+ */
356
+ static #resolveAnchorComponent(
357
+ placement,
358
+ currentComponent,
359
+ components,
360
+ currentHasMetadataAffinity,
361
+ currentHasPartCodeAffinity,
362
+ componentBody
363
+ ) {
364
+ const currentDistance = currentComponent
365
+ ? AltiumScene3dExternalPlacementAdapter.#distanceToBody(
366
+ placement,
367
+ currentComponent
368
+ )
369
+ : Number.POSITIVE_INFINITY
370
+ if (
371
+ currentComponent &&
372
+ currentDistance <=
373
+ AltiumScene3dExternalPlacementAdapter
374
+ .#EXACT_ANCHOR_TOLERANCE_MIL
375
+ ) {
376
+ return null
377
+ }
378
+
379
+ const exactComponent =
380
+ AltiumScene3dExternalPlacementAdapter.#nearestAnchorComponent(
381
+ placement,
382
+ components,
383
+ AltiumScene3dExternalPlacementAdapter
384
+ .#EXACT_ANCHOR_TOLERANCE_MIL
385
+ )
386
+ if (exactComponent) {
387
+ if (
388
+ currentHasMetadataAffinity &&
389
+ !AltiumScene3dExternalPlacementAdapter.#isGenericPassiveComponent(
390
+ currentComponent
391
+ ) &&
392
+ AltiumScene3dExternalPlacementAdapter.#isGenericPassiveComponent(
393
+ exactComponent
394
+ ) &&
395
+ AltiumScene3dExternalPlacementAdapter.#metadataScore(
396
+ AltiumScene3dExternalPlacementAdapter.#bodyIdentityTokens(
397
+ placement,
398
+ componentBody
399
+ ),
400
+ exactComponent
401
+ ) === 0
402
+ ) {
403
+ return null
404
+ }
405
+
406
+ return exactComponent
407
+ }
408
+
409
+ if (
410
+ currentDistance <=
411
+ AltiumScene3dExternalPlacementAdapter.#FAR_OWNER_DISTANCE_MIL
412
+ ) {
413
+ return null
414
+ }
415
+ if (currentHasPartCodeAffinity) {
416
+ return null
417
+ }
418
+ if (
419
+ String(placement?.projection?.source || '') ===
420
+ 'model-anchor-fallback'
421
+ ) {
422
+ return null
423
+ }
424
+
425
+ return AltiumScene3dExternalPlacementAdapter.#nearestAnchorComponent(
426
+ placement,
427
+ components,
428
+ AltiumScene3dExternalPlacementAdapter.#NEAR_ANCHOR_TOLERANCE_MIL
429
+ )
430
+ }
431
+
432
+ /**
433
+ * Repairs orientation fields once a source body and owner are known.
434
+ * @param {object} placement External model placement.
435
+ * @param {object | null | undefined} component Owning component.
436
+ * @param {object | null} componentBody Source component body.
437
+ * @param {boolean} useComponentYaw Whether component yaw should override body yaw.
438
+ * @param {boolean} correctPinOneYaw Whether a square IC pin-one correction applies.
439
+ * @returns {object}
440
+ */
441
+ static #repairRotation(
442
+ placement,
443
+ component,
444
+ componentBody,
445
+ useComponentYaw,
446
+ correctPinOneYaw
447
+ ) {
448
+ const modelTransform =
449
+ AltiumScene3dExternalPlacementAdapter.#repairModelTransform(
450
+ placement?.modelTransform,
451
+ componentBody
452
+ )
453
+ const isGenericPassiveBody =
454
+ AltiumScene3dExternalPlacementAdapter.#isGenericPassiveBody(
455
+ componentBody
456
+ )
457
+ const componentYaw =
458
+ AltiumScene3dExternalPlacementAdapter.#normalizeAngle(
459
+ Number(component?.rotation || 0)
460
+ )
461
+ const placementYaw =
462
+ AltiumScene3dExternalPlacementAdapter.#normalizeAngle(
463
+ Number(placement?.rotationDeg || 0)
464
+ )
465
+ const baseRotation =
466
+ component && useComponentYaw && !isGenericPassiveBody
467
+ ? componentYaw
468
+ : placementYaw
469
+ const rotationDeg =
470
+ correctPinOneYaw && !isGenericPassiveBody
471
+ ? AltiumScene3dExternalPlacementAdapter.#normalizeAngle(
472
+ baseRotation + 180
473
+ )
474
+ : baseRotation
475
+ if (!component || !useComponentYaw || isGenericPassiveBody) {
476
+ return {
477
+ ...placement,
478
+ rotationDeg,
479
+ modelTransform
480
+ }
481
+ }
482
+
483
+ return {
484
+ ...placement,
485
+ rotationDeg,
486
+ modelTransform
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Finds the nearest component whose anchor is effectively the body anchor.
492
+ * @param {object} placement External model placement.
493
+ * @param {object[]} components PCB components.
494
+ * @returns {object | null}
495
+ */
496
+ static #nearestAnchorComponent(placement, components, toleranceMil) {
497
+ const candidates = components
498
+ .map((component) => ({
499
+ component,
500
+ distance: AltiumScene3dExternalPlacementAdapter.#distanceToBody(
501
+ placement,
502
+ component
503
+ )
504
+ }))
505
+ .filter((candidate) => candidate.distance <= toleranceMil)
506
+ .sort((left, right) => left.distance - right.distance)
507
+ return candidates[0]?.component || null
508
+ }
509
+
510
+ /**
511
+ * Builds package metadata text for generic package-family checks.
512
+ * @param {object} component PCB component.
513
+ * @param {object | null} componentBody Source component body.
514
+ * @returns {string}
515
+ */
516
+ static #packageIdentityText(component, componentBody) {
517
+ const parameterValues = Object.values(component?.parameters || {})
518
+ .map((value) => String(value || ''))
519
+ .join(' ')
520
+
521
+ return [
522
+ component?.pattern,
523
+ component?.source,
524
+ component?.description,
525
+ component?.provenance?.footprintDescription,
526
+ parameterValues,
527
+ componentBody?.identifier,
528
+ componentBody?.name
529
+ ]
530
+ .map((value) => String(value || ''))
531
+ .join(' ')
532
+ }
533
+
534
+ /**
535
+ * Matches a standalone offset body back to a component from metadata.
536
+ * @param {object} placement External model placement.
537
+ * @param {object | null} componentBody Source component body.
538
+ * @param {object[]} components PCB components.
539
+ * @returns {object | null}
540
+ */
541
+ static #resolveMetadataComponent(placement, componentBody, components) {
542
+ if (
543
+ AltiumScene3dExternalPlacementAdapter.#isGenericPassiveBody(
544
+ componentBody
545
+ )
546
+ ) {
547
+ return null
548
+ }
549
+
550
+ const tokens =
551
+ AltiumScene3dExternalPlacementAdapter.#bodyIdentityTokens(
552
+ placement,
553
+ componentBody
554
+ )
555
+ if (!tokens.length) {
556
+ return null
557
+ }
558
+
559
+ const candidates = components
560
+ .map((component) => ({
561
+ component,
562
+ score: AltiumScene3dExternalPlacementAdapter.#metadataScore(
563
+ tokens,
564
+ component
565
+ ),
566
+ distance: AltiumScene3dExternalPlacementAdapter.#distanceToBody(
567
+ placement,
568
+ component
569
+ )
570
+ }))
571
+ .filter((candidate) => candidate.score > 0)
572
+ .sort(
573
+ (left, right) =>
574
+ right.score - left.score || left.distance - right.distance
575
+ )
576
+
577
+ return candidates[0]?.component || null
578
+ }
579
+
580
+ /**
581
+ * Checks whether a weak far owner is still supported by source metadata.
582
+ * @param {object} placement External model placement.
583
+ * @param {object | null} componentBody Source component body.
584
+ * @param {object} component PCB component.
585
+ * @returns {boolean}
586
+ */
587
+ static #hasMetadataAffinity(placement, componentBody, component) {
588
+ const tokens =
589
+ AltiumScene3dExternalPlacementAdapter.#bodyIdentityTokens(
590
+ placement,
591
+ componentBody
592
+ )
593
+
594
+ return (
595
+ tokens.length > 0 &&
596
+ AltiumScene3dExternalPlacementAdapter.#metadataScore(
597
+ tokens,
598
+ component
599
+ ) > 0
600
+ )
601
+ }
602
+
603
+ /**
604
+ * Checks whether source metadata confirms ownership with a part-like code
605
+ * instead of only generic package words.
606
+ * @param {object} placement External model placement.
607
+ * @param {object | null} componentBody Source component body.
608
+ * @param {object} component PCB component.
609
+ * @returns {boolean}
610
+ */
611
+ static #hasPartCodeAffinity(placement, componentBody, component) {
612
+ const haystack =
613
+ AltiumScene3dExternalPlacementAdapter.#metadataHaystack(component)
614
+
615
+ return AltiumScene3dExternalPlacementAdapter.#bodyIdentityTokens(
616
+ placement,
617
+ componentBody
618
+ ).some(
619
+ (token) =>
620
+ /[a-z]/u.test(token) &&
621
+ /\d/u.test(token) &&
622
+ haystack.includes(token)
623
+ )
624
+ }
625
+
626
+ /**
627
+ * Scores one component against body identity tokens.
628
+ * @param {string[]} tokens Body identity tokens.
629
+ * @param {object} component PCB component.
630
+ * @returns {number}
631
+ */
632
+ static #metadataScore(tokens, component) {
633
+ const haystack =
634
+ AltiumScene3dExternalPlacementAdapter.#metadataHaystack(component)
635
+
636
+ return tokens.reduce(
637
+ (score, token) =>
638
+ score + (haystack.includes(token) ? token.length : 0),
639
+ 0
640
+ )
641
+ }
642
+
643
+ /**
644
+ * Builds searchable component metadata text.
645
+ * @param {object} component PCB component.
646
+ * @returns {string}
647
+ */
648
+ static #metadataHaystack(component) {
649
+ const parameterValues = Object.values(component?.parameters || {})
650
+ .map((value) => String(value || ''))
651
+ .join(' ')
652
+
653
+ return AltiumScene3dExternalPlacementAdapter.#normalizeIdentityText([
654
+ component?.designator,
655
+ component?.pattern,
656
+ component?.source,
657
+ component?.modelPath,
658
+ parameterValues
659
+ ])
660
+ }
661
+
662
+ /**
663
+ * Collects body identity tokens suitable for exact metadata matching.
664
+ * @param {object} placement External model placement.
665
+ * @param {object | null} componentBody Source component body.
666
+ * @returns {string[]}
667
+ */
668
+ static #bodyIdentityTokens(placement, componentBody) {
669
+ const text = [
670
+ placement?.designator,
671
+ placement?.externalModel?.name,
672
+ placement?.externalModel?.relativePath,
673
+ componentBody?.identifier,
674
+ componentBody?.name
675
+ ]
676
+ .map((value) =>
677
+ String(value || '')
678
+ .replace(/\.[^.]+$/, '')
679
+ .trim()
680
+ )
681
+ .filter(Boolean)
682
+
683
+ return [
684
+ ...new Set(
685
+ text.flatMap((value) =>
686
+ AltiumScene3dIdentityTokens.fromText(value)
687
+ )
688
+ )
689
+ ]
690
+ }
691
+
692
+ /**
693
+ * Resolves the source component body row for one placement.
694
+ * @param {object} placement External model placement.
695
+ * @param {object[]} componentBodies Source component body rows.
696
+ * @returns {object | null}
697
+ */
698
+ static #resolveComponentBody(placement, componentBodies) {
699
+ const candidates = componentBodies
700
+ .map((componentBody) => ({
701
+ componentBody,
702
+ distance:
703
+ AltiumScene3dExternalPlacementAdapter.#distanceBetweenPoints(
704
+ placement?.bodyPositionMil,
705
+ componentBody?.positionMil
706
+ ),
707
+ identityScore:
708
+ AltiumScene3dExternalPlacementAdapter.#bodyPlacementIdentityScore(
709
+ placement,
710
+ componentBody
711
+ )
712
+ }))
713
+ .filter((candidate) => candidate.distance <= 0.01)
714
+ .sort(
715
+ (left, right) =>
716
+ right.identityScore - left.identityScore ||
717
+ left.distance - right.distance
718
+ )
719
+
720
+ return candidates[0]?.componentBody || null
721
+ }
722
+
723
+ /**
724
+ * Scores whether a source body row belongs to one placement.
725
+ * @param {object} placement External model placement.
726
+ * @param {object} componentBody Source component body.
727
+ * @returns {number}
728
+ */
729
+ static #bodyPlacementIdentityScore(placement, componentBody) {
730
+ const placementText =
731
+ AltiumScene3dExternalPlacementAdapter.#normalizeIdentityText([
732
+ placement?.designator,
733
+ placement?.externalModel?.name
734
+ ])
735
+ const bodyText =
736
+ AltiumScene3dExternalPlacementAdapter.#normalizeIdentityText([
737
+ componentBody?.identifier,
738
+ componentBody?.name
739
+ ])
740
+
741
+ return placementText && bodyText && placementText.includes(bodyText)
742
+ ? bodyText.length
743
+ : 0
744
+ }
745
+
746
+ /**
747
+ * Repairs model-local Altium rotation signs for embedded body transforms.
748
+ * @param {object | null | undefined} modelTransform Placement transform.
749
+ * @param {object | null} componentBody Source component body.
750
+ * @returns {object | null | undefined}
751
+ */
752
+ static #repairModelTransform(modelTransform, componentBody) {
753
+ const rotationDeg = modelTransform?.rotationDeg || {}
754
+ const repairedTransform = {
755
+ ...(modelTransform || {}),
756
+ dzMil: AltiumScene3dExternalPlacementAdapter.#repairVerticalOffset(
757
+ modelTransform,
758
+ componentBody
759
+ )
760
+ }
761
+ if (modelTransform?.offsetMil) {
762
+ repairedTransform.offsetMil = {
763
+ ...modelTransform.offsetMil,
764
+ z: repairedTransform.dzMil
765
+ }
766
+ }
767
+
768
+ return {
769
+ ...repairedTransform,
770
+ rotationDeg: {
771
+ ...rotationDeg,
772
+ x: Number(rotationDeg.x ?? 0),
773
+ y: Number(rotationDeg.y ?? 0),
774
+ z: Number(rotationDeg.z ?? 0)
775
+ }
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Clamps only negative standoffs that exceed the source model's height
781
+ * envelope and would sink a seated STEP model through the PCB face.
782
+ * @param {object | null | undefined} modelTransform Placement transform.
783
+ * @param {object | null} componentBody Source component body.
784
+ * @returns {number}
785
+ */
786
+ static #repairVerticalOffset(modelTransform, componentBody) {
787
+ const offsetMil = modelTransform?.offsetMil || {}
788
+ const value = Number(offsetMil.z ?? modelTransform?.dzMil ?? 0)
789
+ if (!Number.isFinite(value)) {
790
+ return 0
791
+ }
792
+
793
+ if (value >= 0) {
794
+ return value
795
+ }
796
+ const overallHeight = Number(componentBody?.overallHeightMil || 0)
797
+ if (overallHeight > 0 && Math.abs(value) < overallHeight) {
798
+ return value
799
+ }
800
+ return 0
801
+ }
802
+
803
+ /**
804
+ * Checks whether a body is a generic passive package where body yaw is safe.
805
+ * @param {object | null} componentBody Source component body.
806
+ * @returns {boolean}
807
+ */
808
+ static #isGenericPassiveBody(componentBody) {
809
+ return AltiumScene3dExternalPlacementAdapter.#PASSIVE_BODY_PATTERN.test(
810
+ [componentBody?.identifier, componentBody?.name].join(' ')
811
+ )
812
+ }
813
+
814
+ /**
815
+ * Checks whether a component's footprint metadata looks like a generic
816
+ * passive package.
817
+ * @param {object | null | undefined} component PCB component.
818
+ * @returns {boolean}
819
+ */
820
+ static #isGenericPassiveComponent(component) {
821
+ const parameterValues = Object.values(component?.parameters || {})
822
+ .map((value) => String(value || ''))
823
+ .join(' ')
824
+
825
+ return AltiumScene3dExternalPlacementAdapter.#PASSIVE_BODY_PATTERN.test(
826
+ [
827
+ component?.pattern,
828
+ component?.source,
829
+ component?.modelPath,
830
+ parameterValues
831
+ ].join(' ')
832
+ )
833
+ }
834
+
835
+ /**
836
+ * Measures the XY distance between one body anchor and one component.
837
+ * @param {object} placement External model placement.
838
+ * @param {object} component PCB component.
839
+ * @returns {number}
840
+ */
841
+ static #distanceToBody(placement, component) {
842
+ return AltiumScene3dExternalPlacementAdapter.#distanceBetweenPoints(
843
+ { x: component?.x, y: component?.y },
844
+ placement?.bodyPositionMil
845
+ )
846
+ }
847
+
848
+ /**
849
+ * Measures the XY distance between two points.
850
+ * @param {{ x?: number, y?: number } | null | undefined} first First point.
851
+ * @param {{ x?: number, y?: number } | null | undefined} second Second point.
852
+ * @returns {number}
853
+ */
854
+ static #distanceBetweenPoints(first, second) {
855
+ return Math.hypot(
856
+ Number(first?.x || 0) - Number(second?.x || 0),
857
+ Number(first?.y || 0) - Number(second?.y || 0)
858
+ )
859
+ }
860
+
861
+ /**
862
+ * Resolves one component's board side from its layer.
863
+ * @param {object} component PCB component.
864
+ * @returns {'top' | 'bottom' | null}
865
+ */
866
+ static #resolveComponentMountSide(component) {
867
+ const layer = String(component?.layer || '').toUpperCase()
868
+ if (layer.includes('BOTTOM') || layer === 'BOT') {
869
+ return 'bottom'
870
+ }
871
+
872
+ if (layer.includes('TOP')) {
873
+ return 'top'
874
+ }
875
+
876
+ return null
877
+ }
878
+
879
+ /**
880
+ * Resolves the board face Z coordinate for one mount side.
881
+ * @param {string} mountSide Mount side.
882
+ * @param {object} board Scene board metadata.
883
+ * @returns {number}
884
+ */
885
+ static #resolveFaceZ(mountSide, board) {
886
+ const thickness =
887
+ Number(board?.thicknessMil) ||
888
+ AltiumScene3dExternalPlacementAdapter.#DEFAULT_BOARD_THICKNESS_MIL
889
+ const halfThickness = thickness / 2
890
+
891
+ return String(mountSide || '').toLowerCase() === 'bottom'
892
+ ? -halfThickness
893
+ : halfThickness
894
+ }
895
+
896
+ /**
897
+ * Normalizes one angle into [0, 360).
898
+ * @param {number} angle Source angle.
899
+ * @returns {number}
900
+ */
901
+ static #normalizeAngle(angle) {
902
+ const normalized = Number(angle || 0) % 360
903
+
904
+ return normalized < 0 ? normalized + 360 : normalized
905
+ }
906
+
907
+ /**
908
+ * Normalizes identity strings for exact substring matching.
909
+ * @param {unknown[]} values Source values.
910
+ * @returns {string}
911
+ */
912
+ static #normalizeIdentityText(values) {
913
+ return values
914
+ .map((value) => String(value || '').toLowerCase())
915
+ .join(' ')
916
+ .replace(/\.[a-z0-9]+\\b/g, '')
917
+ .replace(/[^a-z0-9]+/g, '')
918
+ }
919
+ }