altium-toolkit 1.4.13 → 1.4.15

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,948 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { AltiumScene3dRecoverySpatialIndex } from './AltiumScene3dRecoverySpatialIndex.mjs'
5
+
6
+ /**
7
+ * Recovers component ownership for Altium STEP bodies whose native rows omit
8
+ * a direct owner reference.
9
+ */
10
+ export class AltiumScene3dGeometricOwnerRecovery {
11
+ static #ANCHOR_TOLERANCE_MIL = 8
12
+ static #BODY_INDEX_CELL_MIL = 8
13
+ static #COMPONENT_INDEX_CELL_MIL = 500
14
+ static #MINIMUM_SCORE = 12
15
+ static #MINIMUM_SCORE_MARGIN = 4
16
+
17
+ /**
18
+ * Applies geometry-backed owner recovery to final external placements.
19
+ * @param {object} sceneDescription Built scene description.
20
+ * @param {object} documentModel Parsed Altium document.
21
+ * @returns {object}
22
+ */
23
+ static apply(sceneDescription, documentModel) {
24
+ if (
25
+ String(sceneDescription?.sourceFormat || '').toLowerCase() !==
26
+ 'altium' ||
27
+ !Array.isArray(sceneDescription?.externalPlacements)
28
+ ) {
29
+ return sceneDescription
30
+ }
31
+
32
+ const components = Array.isArray(documentModel?.pcb?.components)
33
+ ? documentModel.pcb.components
34
+ : []
35
+ const bodies = Array.isArray(documentModel?.pcb?.componentBodies)
36
+ ? documentModel.pcb.componentBodies
37
+ : []
38
+ const pads = Array.isArray(documentModel?.pcb?.pads)
39
+ ? documentModel.pcb.pads
40
+ : []
41
+ if (!components.length || !bodies.length || !pads.length) {
42
+ return sceneDescription
43
+ }
44
+
45
+ const componentsByDesignator = new Map(
46
+ components.map((component) => [
47
+ String(component?.designator || ''),
48
+ component
49
+ ])
50
+ )
51
+ const padsByComponent =
52
+ AltiumScene3dRecoverySpatialIndex.padsByComponent(pads)
53
+ const geometryByComponent = new Map(
54
+ components.map((component) => [
55
+ component,
56
+ AltiumScene3dGeometricOwnerRecovery.#componentPadGeometry(
57
+ component,
58
+ padsByComponent.get(Number(component?.componentIndex)) || []
59
+ )
60
+ ])
61
+ )
62
+ const recoveryContext = {
63
+ componentsByDesignator,
64
+ bodyIndex: AltiumScene3dRecoverySpatialIndex.create(
65
+ bodies,
66
+ AltiumScene3dGeometricOwnerRecovery.#BODY_INDEX_CELL_MIL,
67
+ (body) => body?.positionMil
68
+ ),
69
+ geometryByComponent,
70
+ componentIndex: AltiumScene3dRecoverySpatialIndex.create(
71
+ components
72
+ .map((component) => ({
73
+ component,
74
+ geometry: geometryByComponent.get(component)
75
+ }))
76
+ .filter(({ geometry }) => geometry),
77
+ AltiumScene3dGeometricOwnerRecovery.#COMPONENT_INDEX_CELL_MIL,
78
+ ({ component }) => component
79
+ ),
80
+ board: sceneDescription?.board
81
+ }
82
+
83
+ return {
84
+ ...sceneDescription,
85
+ externalPlacements: sceneDescription.externalPlacements.map(
86
+ (placement) =>
87
+ AltiumScene3dGeometricOwnerRecovery.#recoverPlacement(
88
+ placement,
89
+ recoveryContext
90
+ )
91
+ )
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Recovers one placement or applies exact-owner package corrections.
97
+ * @param {object} placement External placement.
98
+ * @param {object} context Pre-indexed recovery data.
99
+ * @returns {object}
100
+ */
101
+ static #recoverPlacement(placement, context) {
102
+ const body = AltiumScene3dGeometricOwnerRecovery.#resolveComponentBody(
103
+ placement,
104
+ context.bodyIndex
105
+ )
106
+ if (!body) return placement
107
+
108
+ const currentOwner = context.componentsByDesignator.get(
109
+ String(placement?.designator || '')
110
+ )
111
+ if (currentOwner) {
112
+ return AltiumScene3dGeometricOwnerRecovery.#correctTactileYaw(
113
+ placement,
114
+ currentOwner,
115
+ body,
116
+ context.geometryByComponent.get(currentOwner)
117
+ )
118
+ }
119
+
120
+ const match =
121
+ AltiumScene3dGeometricOwnerRecovery.#resolveGeometricOwner(
122
+ placement,
123
+ body,
124
+ context
125
+ )
126
+ if (!match) return placement
127
+
128
+ const recovered = AltiumScene3dGeometricOwnerRecovery.#withOwner(
129
+ placement,
130
+ body,
131
+ match,
132
+ context.board
133
+ )
134
+ return AltiumScene3dGeometricOwnerRecovery.#correctTactileYaw(
135
+ recovered,
136
+ match.component,
137
+ body,
138
+ context.geometryByComponent.get(match.component)
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Resolves the source body row occupying a placement anchor.
144
+ * @param {object} placement External placement.
145
+ * @param {Map<string, object[]>} bodyIndex Spatial body index.
146
+ * @returns {object | null}
147
+ */
148
+ static #resolveComponentBody(placement, bodyIndex) {
149
+ const anchor = placement?.bodyPositionMil
150
+ if (!anchor) return null
151
+
152
+ const matches = AltiumScene3dRecoverySpatialIndex.nearby(
153
+ bodyIndex,
154
+ anchor,
155
+ AltiumScene3dGeometricOwnerRecovery.#BODY_INDEX_CELL_MIL,
156
+ 1
157
+ )
158
+ .map((body) => ({
159
+ body,
160
+ distance: AltiumScene3dGeometricOwnerRecovery.#distance(
161
+ anchor,
162
+ body?.positionMil
163
+ ),
164
+ identity:
165
+ AltiumScene3dGeometricOwnerRecovery.#bodyIdentityScore(
166
+ placement,
167
+ body
168
+ )
169
+ }))
170
+ .filter(
171
+ (candidate) =>
172
+ candidate.distance <=
173
+ AltiumScene3dGeometricOwnerRecovery.#ANCHOR_TOLERANCE_MIL
174
+ )
175
+ .sort(
176
+ (left, right) =>
177
+ right.identity - left.identity ||
178
+ left.distance - right.distance
179
+ )
180
+
181
+ const best = matches[0]
182
+ const second = matches[1]
183
+ if (
184
+ second &&
185
+ best.identity === second.identity &&
186
+ Math.abs(best.distance - second.distance) < 1e-6
187
+ ) {
188
+ return null
189
+ }
190
+ return best?.body || null
191
+ }
192
+
193
+ /**
194
+ * Scores body identity without requiring vendor or library tokens.
195
+ * @param {object} placement External placement.
196
+ * @param {object} body Source body.
197
+ * @returns {number}
198
+ */
199
+ static #bodyIdentityScore(placement, body) {
200
+ const placementText =
201
+ AltiumScene3dGeometricOwnerRecovery.#normalizeIdentity([
202
+ placement?.designator,
203
+ placement?.externalModel?.name
204
+ ])
205
+ const bodyText = AltiumScene3dGeometricOwnerRecovery.#normalizeIdentity(
206
+ [body?.identifier, body?.name]
207
+ )
208
+
209
+ return placementText && bodyText && placementText.includes(bodyText)
210
+ ? bodyText.length
211
+ : 0
212
+ }
213
+
214
+ /**
215
+ * Selects only components within the model-dependent recovery radius.
216
+ * @param {object} placement External placement.
217
+ * @param {Map<string, object[]>} componentIndex Component spatial index.
218
+ * @returns {{ component: object, geometry: object }[]}
219
+ */
220
+ static #nearbyComponentGeometry(placement, componentIndex) {
221
+ const longestModelDimension = Math.max(
222
+ 0,
223
+ ...AltiumScene3dGeometricOwnerRecovery.#modelDimensions(placement)
224
+ )
225
+ const radiusMil = Math.max(300, longestModelDimension)
226
+ const cellRadius = Math.ceil(
227
+ radiusMil /
228
+ AltiumScene3dGeometricOwnerRecovery.#COMPONENT_INDEX_CELL_MIL
229
+ )
230
+ return AltiumScene3dRecoverySpatialIndex.nearby(
231
+ componentIndex,
232
+ placement?.bodyPositionMil,
233
+ AltiumScene3dGeometricOwnerRecovery.#COMPONENT_INDEX_CELL_MIL,
234
+ cellRadius
235
+ ).filter(
236
+ ({ component }) =>
237
+ AltiumScene3dGeometricOwnerRecovery.#distance(
238
+ placement?.bodyPositionMil,
239
+ component
240
+ ) <= radiusMil
241
+ )
242
+ }
243
+
244
+ /**
245
+ * Resolves a unique geometry-backed owner candidate.
246
+ * @param {object} placement External placement.
247
+ * @param {object} body Source component body.
248
+ * @param {object} context Pre-indexed recovery data.
249
+ * @returns {{ component: object, mode: string, score: number } | null}
250
+ */
251
+ static #resolveGeometricOwner(placement, body, context) {
252
+ const candidates =
253
+ AltiumScene3dGeometricOwnerRecovery.#nearbyComponentGeometry(
254
+ placement,
255
+ context.componentIndex
256
+ )
257
+ .map(({ component, geometry }) =>
258
+ AltiumScene3dGeometricOwnerRecovery.#scoreCandidate(
259
+ placement,
260
+ body,
261
+ component,
262
+ geometry
263
+ )
264
+ )
265
+ .filter(Boolean)
266
+ .sort(
267
+ (left, right) =>
268
+ right.score - left.score ||
269
+ left.distance - right.distance
270
+ )
271
+ const best = candidates[0]
272
+ const second = candidates[1]
273
+
274
+ if (
275
+ !best ||
276
+ best.score < AltiumScene3dGeometricOwnerRecovery.#MINIMUM_SCORE ||
277
+ (second &&
278
+ best.score - second.score <
279
+ AltiumScene3dGeometricOwnerRecovery.#MINIMUM_SCORE_MARGIN)
280
+ ) {
281
+ return null
282
+ }
283
+
284
+ return best
285
+ }
286
+
287
+ /**
288
+ * Scores one candidate from owned pad geometry and source dimensions.
289
+ * @param {object} placement External placement.
290
+ * @param {object} body Source component body.
291
+ * @param {object} component Candidate component.
292
+ * @param {object | null} geometry Precomputed pad geometry.
293
+ * @returns {{ component: object, mode: string, score: number, distance: number } | null}
294
+ */
295
+ static #scoreCandidate(placement, body, component, geometry) {
296
+ if (!geometry || geometry.pads.length < 2) return null
297
+
298
+ const anchor = placement?.bodyPositionMil || {}
299
+ const distance = AltiumScene3dGeometricOwnerRecovery.#distance(
300
+ anchor,
301
+ component
302
+ )
303
+ const centroidDistance = AltiumScene3dGeometricOwnerRecovery.#distance(
304
+ anchor,
305
+ geometry.center
306
+ )
307
+ const corner = AltiumScene3dGeometricOwnerRecovery.#isModelCornerOrigin(
308
+ placement,
309
+ component,
310
+ geometry
311
+ )
312
+ const rowOrigin = AltiumScene3dGeometricOwnerRecovery.#isMultiRowOrigin(
313
+ placement,
314
+ geometry
315
+ )
316
+ const heightAgreement =
317
+ AltiumScene3dGeometricOwnerRecovery.#hasHeightAgreement(
318
+ body,
319
+ component
320
+ )
321
+ const nearFootprint =
322
+ AltiumScene3dGeometricOwnerRecovery.#isNearFootprint(
323
+ anchor,
324
+ geometry,
325
+ 50
326
+ )
327
+ let score = 0
328
+ let mode = ''
329
+
330
+ if (
331
+ centroidDistance <=
332
+ AltiumScene3dGeometricOwnerRecovery.#ANCHOR_TOLERANCE_MIL
333
+ ) {
334
+ score = 24
335
+ mode = 'pad-centroid'
336
+ } else if (corner) {
337
+ score = 20
338
+ mode = 'model-corner'
339
+ } else if (rowOrigin) {
340
+ score = 18
341
+ mode = 'multi-row-origin'
342
+ } else if (heightAgreement && nearFootprint && distance <= 250) {
343
+ score = 14
344
+ mode = 'height-backed-origin'
345
+ }
346
+
347
+ if (!score) return null
348
+ if (heightAgreement) score += 2
349
+ score += Math.max(0, 3 - distance / 100)
350
+
351
+ return { component, mode, score, distance }
352
+ }
353
+
354
+ /**
355
+ * Builds bounds and topology for pads owned by one component.
356
+ * @param {object} component PCB component.
357
+ * @param {object[]} ownedPads Pads already indexed to this component.
358
+ * @returns {object | null}
359
+ */
360
+ static #componentPadGeometry(component, ownedPads) {
361
+ const componentIndex = Number(component?.componentIndex)
362
+ if (!Number.isFinite(componentIndex)) return null
363
+ if (!ownedPads.length) return null
364
+
365
+ const centerXs = ownedPads.map((pad) => Number(pad?.x || 0))
366
+ const centerYs = ownedPads.map((pad) => Number(pad?.y || 0))
367
+ const center = {
368
+ x:
369
+ centerXs.reduce((sum, value) => sum + value, 0) /
370
+ centerXs.length,
371
+ y: centerYs.reduce((sum, value) => sum + value, 0) / centerYs.length
372
+ }
373
+ const localPads = ownedPads.map((pad) => ({
374
+ source: pad,
375
+ ...AltiumScene3dGeometricOwnerRecovery.#toLocalPoint(
376
+ pad,
377
+ center,
378
+ component?.rotation
379
+ )
380
+ }))
381
+ const localXs = localPads.map((pad) => pad.x)
382
+ const localYs = localPads.map((pad) => pad.y)
383
+ const minX = Math.min(
384
+ ...ownedPads.map(
385
+ (pad) =>
386
+ Number(pad?.x || 0) -
387
+ AltiumScene3dGeometricOwnerRecovery.#padWidth(pad) / 2
388
+ )
389
+ )
390
+ const maxX = Math.max(
391
+ ...ownedPads.map(
392
+ (pad) =>
393
+ Number(pad?.x || 0) +
394
+ AltiumScene3dGeometricOwnerRecovery.#padWidth(pad) / 2
395
+ )
396
+ )
397
+ const minY = Math.min(
398
+ ...ownedPads.map(
399
+ (pad) =>
400
+ Number(pad?.y || 0) -
401
+ AltiumScene3dGeometricOwnerRecovery.#padDepth(pad) / 2
402
+ )
403
+ )
404
+ const maxY = Math.max(
405
+ ...ownedPads.map(
406
+ (pad) =>
407
+ Number(pad?.y || 0) +
408
+ AltiumScene3dGeometricOwnerRecovery.#padDepth(pad) / 2
409
+ )
410
+ )
411
+
412
+ return {
413
+ pads: ownedPads,
414
+ localPads,
415
+ center,
416
+ bounds: { minX, maxX, minY, maxY },
417
+ width: maxX - minX,
418
+ depth: maxY - minY,
419
+ xCount: AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
420
+ localXs
421
+ ),
422
+ yCount: AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
423
+ localYs
424
+ )
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Rotates one board point into the component-local footprint frame.
430
+ * @param {object} point Board point.
431
+ * @param {object} center Footprint center.
432
+ * @param {unknown} rotationDeg Component rotation.
433
+ * @returns {{ x: number, y: number }}
434
+ */
435
+ static #toLocalPoint(point, center, rotationDeg) {
436
+ const radians =
437
+ (-AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(rotationDeg) *
438
+ Math.PI) /
439
+ 180
440
+ const dx = Number(point?.x || 0) - Number(center?.x || 0)
441
+ const dy = Number(point?.y || 0) - Number(center?.y || 0)
442
+ return {
443
+ x: dx * Math.cos(radians) - dy * Math.sin(radians),
444
+ y: dx * Math.sin(radians) + dy * Math.cos(radians)
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Checks whether a body anchor is the opposite corner of measured model
450
+ * dimensions from the component origin.
451
+ * @param {object} placement External placement.
452
+ * @param {object} component Candidate component.
453
+ * @param {object} geometry Owned pad geometry.
454
+ * @returns {boolean}
455
+ */
456
+ static #isModelCornerOrigin(placement, component, geometry) {
457
+ const offsetDimensions = [
458
+ Math.abs(
459
+ Number(placement?.bodyPositionMil?.x || 0) -
460
+ Number(component?.x || 0)
461
+ ) * 2,
462
+ Math.abs(
463
+ Number(placement?.bodyPositionMil?.y || 0) -
464
+ Number(component?.y || 0)
465
+ ) * 2
466
+ ]
467
+ if (offsetDimensions.some((dimension) => dimension < 20)) return false
468
+
469
+ const modelDimensions =
470
+ AltiumScene3dGeometricOwnerRecovery.#modelDimensions(placement)
471
+ const offsetMatchesModel =
472
+ AltiumScene3dGeometricOwnerRecovery.#matchesDimensionPair(
473
+ offsetDimensions,
474
+ modelDimensions,
475
+ 0.12,
476
+ 25
477
+ )
478
+ const modelMatchesFootprint =
479
+ AltiumScene3dGeometricOwnerRecovery.#matchesDimensionPair(
480
+ [geometry.width, geometry.depth],
481
+ modelDimensions,
482
+ 0.3,
483
+ 45
484
+ )
485
+
486
+ return offsetMatchesModel && modelMatchesFootprint
487
+ }
488
+
489
+ /**
490
+ * Checks whether a multi-row footprint and model share the same long span.
491
+ * @param {object} placement External placement.
492
+ * @param {object} geometry Owned pad geometry.
493
+ * @returns {boolean}
494
+ */
495
+ static #isMultiRowOrigin(placement, geometry) {
496
+ const isGrid =
497
+ geometry.pads.length >= 6 &&
498
+ ((geometry.xCount >= 3 && geometry.yCount === 2) ||
499
+ (geometry.yCount >= 3 && geometry.xCount === 2))
500
+ if (!isGrid) return false
501
+ if (
502
+ !AltiumScene3dGeometricOwnerRecovery.#isNearFootprint(
503
+ placement?.bodyPositionMil,
504
+ geometry,
505
+ 10
506
+ )
507
+ ) {
508
+ return false
509
+ }
510
+
511
+ const modelDimensions =
512
+ AltiumScene3dGeometricOwnerRecovery.#modelDimensions(placement)
513
+ const modelLong = Math.max(...modelDimensions)
514
+ const footprintLong = Math.max(geometry.width, geometry.depth)
515
+
516
+ return AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
517
+ modelLong,
518
+ footprintLong,
519
+ 0.18,
520
+ 50
521
+ )
522
+ }
523
+
524
+ /**
525
+ * Checks whether source and component heights agree.
526
+ * @param {object} body Source body.
527
+ * @param {object} component Candidate component.
528
+ * @returns {boolean}
529
+ */
530
+ static #hasHeightAgreement(body, component) {
531
+ const sourceHeight = body?.overallHeightMil
532
+ const componentHeight = component?.height
533
+ if (
534
+ sourceHeight === null ||
535
+ sourceHeight === undefined ||
536
+ sourceHeight === '' ||
537
+ componentHeight === null ||
538
+ componentHeight === undefined ||
539
+ componentHeight === ''
540
+ ) {
541
+ return false
542
+ }
543
+ const normalizedSourceHeight = Number(sourceHeight)
544
+ const normalizedComponentHeight = Number(componentHeight)
545
+ if (
546
+ !Number.isFinite(normalizedSourceHeight) ||
547
+ normalizedSourceHeight <= 0 ||
548
+ !Number.isFinite(normalizedComponentHeight) ||
549
+ normalizedComponentHeight <= 0
550
+ ) {
551
+ return false
552
+ }
553
+ return AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
554
+ normalizedSourceHeight,
555
+ normalizedComponentHeight,
556
+ 0.08,
557
+ 3
558
+ )
559
+ }
560
+
561
+ /**
562
+ * Applies a recovered owner and preserves only proven authored offsets.
563
+ * @param {object} placement External placement.
564
+ * @param {object} body Source body.
565
+ * @param {{ component: object, mode: string }} match Owner match.
566
+ * @param {object} board Board metadata.
567
+ * @returns {object}
568
+ */
569
+ static #withOwner(placement, body, match, board) {
570
+ const component = match.component
571
+ const mountSide =
572
+ AltiumScene3dGeometricOwnerRecovery.#componentSide(component)
573
+ const preserveAnchor = match.mode === 'pad-centroid'
574
+ const offset = {
575
+ x:
576
+ Number(placement?.bodyPositionMil?.x || 0) -
577
+ Number(component?.x || 0),
578
+ y:
579
+ Number(placement?.bodyPositionMil?.y || 0) -
580
+ Number(component?.y || 0)
581
+ }
582
+ const verticalOffset =
583
+ AltiumScene3dGeometricOwnerRecovery.#verticalOffset(
584
+ body,
585
+ mountSide,
586
+ placement?.modelTransform
587
+ )
588
+ const modelTransform = {
589
+ ...(placement?.modelTransform || {}),
590
+ dzMil: verticalOffset
591
+ }
592
+
593
+ if (!preserveAnchor) {
594
+ modelTransform.ownerAnchorOffsetMil = offset
595
+ modelTransform.offsetMil = { x: 0, y: 0, z: verticalOffset }
596
+ } else if (modelTransform.offsetMil) {
597
+ modelTransform.offsetMil = {
598
+ ...modelTransform.offsetMil,
599
+ z: verticalOffset
600
+ }
601
+ }
602
+
603
+ return {
604
+ ...placement,
605
+ designator: String(component?.designator || placement.designator),
606
+ mountSide,
607
+ positionMil: {
608
+ ...placement.positionMil,
609
+ ...(preserveAnchor
610
+ ? {}
611
+ : {
612
+ x:
613
+ Number(component?.x || 0) -
614
+ Number(board?.centerX || 0),
615
+ y:
616
+ Number(component?.y || 0) -
617
+ Number(board?.centerY || 0)
618
+ }),
619
+ z: AltiumScene3dGeometricOwnerRecovery.#faceZ(mountSide, board)
620
+ },
621
+ modelTransform
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Corrects the source-frame half-turn for a four-pad tactile switch.
627
+ * @param {object} placement External placement.
628
+ * @param {object} component Resolved owner.
629
+ * @param {object} body Source body.
630
+ * @param {object | null} geometry Precomputed owner pad geometry.
631
+ * @returns {object}
632
+ */
633
+ static #correctTactileYaw(placement, component, body, geometry) {
634
+ const identity = [
635
+ component?.designator,
636
+ component?.description,
637
+ component?.provenance?.footprintDescription
638
+ ]
639
+ .map((value) => String(value || ''))
640
+ .join(' ')
641
+ const sourceTilt = AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
642
+ body?.modelRotationDeg?.x
643
+ )
644
+ const currentYaw = AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
645
+ placement?.rotationDeg
646
+ )
647
+ const componentYaw =
648
+ AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
649
+ component?.rotation
650
+ )
651
+ const isTactileSwitch =
652
+ /(?:^|[^a-z0-9])(?:tact|tactile|pushbutton)(?:$|[^a-z0-9])/i.test(
653
+ identity
654
+ ) &&
655
+ AltiumScene3dGeometricOwnerRecovery.#hasTactileContactTopology(
656
+ geometry
657
+ )
658
+
659
+ if (
660
+ !isTactileSwitch ||
661
+ (sourceTilt !== 90 && sourceTilt !== 270) ||
662
+ currentYaw !== componentYaw
663
+ ) {
664
+ return placement
665
+ }
666
+
667
+ return {
668
+ ...placement,
669
+ rotationDeg: AltiumScene3dGeometricOwnerRecovery.#normalizeAngle(
670
+ currentYaw + 180
671
+ )
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Detects a two-by-two tactile contact layout with two duplicated routed
677
+ * contact pairs aligned along one footprint axis.
678
+ * @param {object | null} geometry Precomputed owner pad geometry.
679
+ * @returns {boolean}
680
+ */
681
+ static #hasTactileContactTopology(geometry) {
682
+ if (
683
+ geometry?.pads?.length !== 4 ||
684
+ geometry.xCount !== 2 ||
685
+ geometry.yCount !== 2
686
+ ) {
687
+ return false
688
+ }
689
+
690
+ const groups = new Map()
691
+ for (const pad of geometry.localPads) {
692
+ const sourcePad = pad.source
693
+ const netName = String(sourcePad?.netName || '').trim()
694
+ const netIndex = sourcePad?.netIndex
695
+ const contactKey = netName
696
+ ? `name:${netName}`
697
+ : netIndex !== null &&
698
+ netIndex !== undefined &&
699
+ netIndex !== '' &&
700
+ Number.isFinite(Number(netIndex))
701
+ ? `index:${Number(netIndex)}`
702
+ : ''
703
+ if (!contactKey) return false
704
+ groups.set(contactKey, [...(groups.get(contactKey) || []), pad])
705
+ }
706
+ if (
707
+ groups.size !== 2 ||
708
+ [...groups.values()].some((group) => group.length !== 2)
709
+ ) {
710
+ return false
711
+ }
712
+
713
+ return [...groups.values()].every((group) => {
714
+ const sameX =
715
+ AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
716
+ group.map((pad) => Number(pad?.x || 0))
717
+ ) === 1
718
+ const sameY =
719
+ AltiumScene3dGeometricOwnerRecovery.#distinctCoordinateCount(
720
+ group.map((pad) => Number(pad?.y || 0))
721
+ ) === 1
722
+ return sameX || sameY
723
+ })
724
+ }
725
+
726
+ /**
727
+ * Resolves a body vertical offset after a late owner recovery.
728
+ * @param {object} body Source body.
729
+ * @param {'top' | 'bottom'} mountSide Recovered side.
730
+ * @param {object} currentTransform Existing model transform.
731
+ * @returns {number}
732
+ */
733
+ static #verticalOffset(body, mountSide, currentTransform) {
734
+ const standoff = Number(body?.standoffHeightMil)
735
+ const overallHeight = Number(body?.overallHeightMil)
736
+ if (
737
+ mountSide === 'top' &&
738
+ Number.isFinite(standoff) &&
739
+ standoff < 0 &&
740
+ (!Number.isFinite(overallHeight) ||
741
+ overallHeight <= 0 ||
742
+ Math.abs(standoff) < overallHeight)
743
+ ) {
744
+ return standoff
745
+ }
746
+ if (Number.isFinite(standoff) && Math.abs(standoff) < 1e-6) return 0
747
+
748
+ const current = Number(
749
+ currentTransform?.offsetMil?.z ?? currentTransform?.dzMil ?? 0
750
+ )
751
+ return Number.isFinite(current) ? current : 0
752
+ }
753
+
754
+ /**
755
+ * Returns measured model dimensions.
756
+ * @param {object} placement External placement.
757
+ * @returns {number[]}
758
+ */
759
+ static #modelDimensions(placement) {
760
+ return [
761
+ Number(placement?.projection?.boundsMil?.width),
762
+ Number(placement?.projection?.boundsMil?.depth),
763
+ Number(placement?.projection?.boundsMil?.height)
764
+ ].filter((dimension) => Number.isFinite(dimension) && dimension > 0)
765
+ }
766
+
767
+ /**
768
+ * Checks whether two source dimensions match any distinct model axes.
769
+ * @param {number[]} sourceDimensions Two source dimensions.
770
+ * @param {number[]} modelDimensions Candidate model dimensions.
771
+ * @param {number} ratio Relative tolerance.
772
+ * @param {number} minimum Absolute tolerance.
773
+ * @returns {boolean}
774
+ */
775
+ static #matchesDimensionPair(
776
+ sourceDimensions,
777
+ modelDimensions,
778
+ ratio,
779
+ minimum
780
+ ) {
781
+ if (sourceDimensions.length < 2 || modelDimensions.length < 2) {
782
+ return false
783
+ }
784
+
785
+ return modelDimensions.some((first, firstIndex) =>
786
+ modelDimensions.some(
787
+ (second, secondIndex) =>
788
+ firstIndex !== secondIndex &&
789
+ ((AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
790
+ sourceDimensions[0],
791
+ first,
792
+ ratio,
793
+ minimum
794
+ ) &&
795
+ AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
796
+ sourceDimensions[1],
797
+ second,
798
+ ratio,
799
+ minimum
800
+ )) ||
801
+ (AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
802
+ sourceDimensions[0],
803
+ second,
804
+ ratio,
805
+ minimum
806
+ ) &&
807
+ AltiumScene3dGeometricOwnerRecovery.#matchesDimension(
808
+ sourceDimensions[1],
809
+ first,
810
+ ratio,
811
+ minimum
812
+ )))
813
+ )
814
+ )
815
+ }
816
+
817
+ /**
818
+ * Checks one dimension with combined absolute and relative tolerance.
819
+ * @param {number} actual Actual dimension.
820
+ * @param {number} expected Expected dimension.
821
+ * @param {number} ratio Relative tolerance.
822
+ * @param {number} minimum Absolute tolerance.
823
+ * @returns {boolean}
824
+ */
825
+ static #matchesDimension(actual, expected, ratio, minimum) {
826
+ if (!Number.isFinite(actual) || !Number.isFinite(expected)) {
827
+ return false
828
+ }
829
+ const tolerance = Math.max(minimum, Math.abs(expected) * ratio)
830
+ return Math.abs(actual - expected) <= tolerance
831
+ }
832
+
833
+ /**
834
+ * Checks whether a point lies within expanded footprint bounds.
835
+ * @param {object} point Board point.
836
+ * @param {object} geometry Pad geometry.
837
+ * @param {number} expansion Bounds expansion.
838
+ * @returns {boolean}
839
+ */
840
+ static #isNearFootprint(point, geometry, expansion) {
841
+ return (
842
+ Number(point?.x) >= geometry.bounds.minX - expansion &&
843
+ Number(point?.x) <= geometry.bounds.maxX + expansion &&
844
+ Number(point?.y) >= geometry.bounds.minY - expansion &&
845
+ Number(point?.y) <= geometry.bounds.maxY + expansion
846
+ )
847
+ }
848
+
849
+ /**
850
+ * Counts coordinates after grouping native numeric noise.
851
+ * @param {number[]} values Coordinates.
852
+ * @returns {number}
853
+ */
854
+ static #distinctCoordinateCount(values) {
855
+ return new Set(values.map((value) => Math.round(value * 10))).size
856
+ }
857
+
858
+ /**
859
+ * Resolves effective pad width.
860
+ * @param {object} pad PCB pad.
861
+ * @returns {number}
862
+ */
863
+ static #padWidth(pad) {
864
+ return Math.max(
865
+ Number(pad?.sizeTopX || 0),
866
+ Number(pad?.sizeMidX || 0),
867
+ Number(pad?.sizeBottomX || 0)
868
+ )
869
+ }
870
+
871
+ /**
872
+ * Resolves effective pad depth.
873
+ * @param {object} pad PCB pad.
874
+ * @returns {number}
875
+ */
876
+ static #padDepth(pad) {
877
+ return Math.max(
878
+ Number(pad?.sizeTopY || 0),
879
+ Number(pad?.sizeMidY || 0),
880
+ Number(pad?.sizeBottomY || 0)
881
+ )
882
+ }
883
+
884
+ /**
885
+ * Resolves component side.
886
+ * @param {object} component PCB component.
887
+ * @returns {'top' | 'bottom'}
888
+ */
889
+ static #componentSide(component) {
890
+ return /bottom|bot/i.test(String(component?.layer || ''))
891
+ ? 'bottom'
892
+ : 'top'
893
+ }
894
+
895
+ /**
896
+ * Resolves board face Z.
897
+ * @param {'top' | 'bottom'} mountSide Placement side.
898
+ * @param {object} board Board metadata.
899
+ * @returns {number}
900
+ */
901
+ static #faceZ(mountSide, board) {
902
+ const halfThickness = Number(board?.thicknessMil || 63) / 2
903
+ return mountSide === 'bottom' ? -halfThickness : halfThickness
904
+ }
905
+
906
+ /**
907
+ * Measures planar distance.
908
+ * @param {object} first First point.
909
+ * @param {object} second Second point.
910
+ * @returns {number}
911
+ */
912
+ static #distance(first, second) {
913
+ return Math.hypot(
914
+ Number(first?.x || 0) - Number(second?.x || 0),
915
+ Number(first?.y || 0) - Number(second?.y || 0)
916
+ )
917
+ }
918
+
919
+ /**
920
+ * Normalizes identity text.
921
+ * @param {unknown[]} values Identity values.
922
+ * @returns {string}
923
+ */
924
+ static #normalizeIdentity(values) {
925
+ return values
926
+ .map((value) =>
927
+ String(value || '')
928
+ .replace(/\.[^.]+$/, '')
929
+ .toLowerCase()
930
+ .replace(/[^a-z0-9]+/g, '')
931
+ )
932
+ .filter(Boolean)
933
+ .join(' ')
934
+ }
935
+
936
+ /**
937
+ * Normalizes one angle.
938
+ * @param {unknown} angle Angle value.
939
+ * @returns {number}
940
+ */
941
+ static #normalizeAngle(angle) {
942
+ const normalized = Number(angle || 0) % 360
943
+ return normalized < 0 ? normalized + 360 : normalized
944
+ }
945
+ }
946
+
947
+ Object.freeze(AltiumScene3dGeometricOwnerRecovery.prototype)
948
+ Object.freeze(AltiumScene3dGeometricOwnerRecovery)