altium-toolkit 1.1.32 → 1.1.35

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 (28) hide show
  1. package/docs/model-format.md +2 -3
  2. package/package.json +1 -1
  3. package/src/core/altium/AltiumGeneratedLibraryRecordBuilder.mjs +551 -0
  4. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +286 -2
  5. package/src/core/altium/AltiumPcbLibExporter.mjs +9 -2
  6. package/src/core/altium/AltiumSchLibExporter.mjs +1 -1
  7. package/src/core/altium/PcbComponentBodyPlacementNormalizer.mjs +120 -15
  8. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +52 -4
  9. package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +85 -11
  10. package/src/core/altium/SourceComponentBundleNormalizer.mjs +31 -0
  11. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  12. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +30 -2
  13. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +27 -2
  14. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +44 -0
  15. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +171 -6
  16. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +782 -0
  17. package/src/ui/AltiumScene3dTwoRowFootprintDetector.mjs +90 -0
  18. package/src/ui/PcbScene3dBuilder.mjs +595 -28
  19. package/src/ui/PcbScene3dCopperRegionDetailBuilder.mjs +280 -0
  20. package/src/ui/PcbScene3dModelRegistry.mjs +16 -0
  21. package/src/ui/PcbScene3dPackageDimensionResolver.mjs +107 -0
  22. package/src/ui/PcbScene3dPackages.mjs +15 -3
  23. package/src/ui/PcbScene3dPadYawResolver.mjs +200 -0
  24. package/src/ui/PcbScene3dPlacementSideResolver.mjs +56 -0
  25. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +638 -16
  26. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +891 -0
  27. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +358 -0
  28. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +876 -0
@@ -0,0 +1,782 @@
1
+ const TIMING_PACKAGE_PATTERN =
2
+ /(?:^|[^a-z0-9])(?:clock|crystal|osc|oscillator|resonator|tcxo|txco|xtal)(?:$|[^a-z0-9])/i
3
+ const TIMING_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
4
+
5
+ /**
6
+ * Keeps authored shape-based sub-bodies grouped under their carrier owner.
7
+ */
8
+ export class AltiumScene3dShapeStackOwnerAdapter {
9
+ static #BASE_OWNER_TOLERANCE_MIL = 60
10
+ static #EXACT_OWNER_TOLERANCE_MIL = 1
11
+ static #STACK_BODY_RADIUS_MIL = 220
12
+ static #HEIGHT_TOLERANCE_MIL = 0.1
13
+
14
+ /**
15
+ * Applies authored stack ownership to static and external placements.
16
+ * @param {object} sceneDescription Built scene description.
17
+ * @param {object} documentModel Source document model.
18
+ * @returns {object}
19
+ */
20
+ static apply(sceneDescription, documentModel) {
21
+ if (
22
+ String(sceneDescription?.sourceFormat || '').toLowerCase() !==
23
+ 'altium'
24
+ ) {
25
+ return sceneDescription
26
+ }
27
+
28
+ const components = Array.isArray(documentModel?.pcb?.components)
29
+ ? documentModel.pcb.components
30
+ : []
31
+ const componentBodies = Array.isArray(
32
+ documentModel?.pcb?.componentBodies
33
+ )
34
+ ? documentModel.pcb.componentBodies
35
+ : []
36
+ if (!components.length || !componentBodies.length) {
37
+ return sceneDescription
38
+ }
39
+
40
+ const assignments =
41
+ AltiumScene3dShapeStackOwnerAdapter.#carrierAssignments(
42
+ componentBodies,
43
+ components
44
+ )
45
+ if (!assignments.length) {
46
+ return sceneDescription
47
+ }
48
+
49
+ const ownerGroupKeys = new Map(
50
+ assignments.map((assignment) => [
51
+ String(assignment.owner.designator || ''),
52
+ assignment.groupKey
53
+ ])
54
+ )
55
+
56
+ return {
57
+ ...sceneDescription,
58
+ components: AltiumScene3dShapeStackOwnerAdapter.#markComponents(
59
+ sceneDescription.components,
60
+ ownerGroupKeys
61
+ ),
62
+ externalPlacements:
63
+ AltiumScene3dShapeStackOwnerAdapter.#repairExternalPlacements(
64
+ sceneDescription.externalPlacements,
65
+ componentBodies,
66
+ assignments,
67
+ sceneDescription.board
68
+ ),
69
+ staticBodyPlacements:
70
+ AltiumScene3dShapeStackOwnerAdapter.#repairStaticPlacements(
71
+ sceneDescription.staticBodyPlacements,
72
+ componentBodies,
73
+ assignments,
74
+ sceneDescription.board
75
+ )
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Marks stack owners so the viewer skips their generated fallback body.
81
+ * @param {object[] | undefined} components Scene components.
82
+ * @param {Map<string, string>} ownerGroupKeys Authored stack owners.
83
+ * @returns {object[]}
84
+ */
85
+ static #markComponents(components, ownerGroupKeys) {
86
+ return (Array.isArray(components) ? components : []).map((component) =>
87
+ ownerGroupKeys.has(String(component?.designator || ''))
88
+ ? {
89
+ ...component,
90
+ renderFallbackBody: false,
91
+ coLocatedVariantGroupKey: ownerGroupKeys.get(
92
+ String(component?.designator || '')
93
+ )
94
+ }
95
+ : component
96
+ )
97
+ }
98
+
99
+ /**
100
+ * Repairs external model ownership for authored stack sub-bodies.
101
+ * @param {object[] | undefined} placements Scene external placements.
102
+ * @param {object[]} componentBodies Source component-body rows.
103
+ * @param {object[]} assignments Carrier assignments.
104
+ * @param {object | undefined} board Scene board metadata.
105
+ * @returns {object[]}
106
+ */
107
+ static #repairExternalPlacements(
108
+ placements,
109
+ componentBodies,
110
+ assignments,
111
+ board
112
+ ) {
113
+ const usedComponentBodies = new Set()
114
+
115
+ return (Array.isArray(placements) ? placements : []).map(
116
+ (placement) => {
117
+ const componentBody =
118
+ AltiumScene3dShapeStackOwnerAdapter.#resolveComponentBody(
119
+ placement,
120
+ componentBodies,
121
+ usedComponentBodies
122
+ )
123
+ if (componentBody) {
124
+ usedComponentBodies.add(componentBody)
125
+ }
126
+
127
+ const assignment =
128
+ AltiumScene3dShapeStackOwnerAdapter.#assignmentForBody(
129
+ componentBody,
130
+ assignments
131
+ )
132
+ if (!assignment) {
133
+ return placement
134
+ }
135
+
136
+ return AltiumScene3dShapeStackOwnerAdapter.#withOwner(
137
+ placement,
138
+ componentBody,
139
+ assignment,
140
+ board
141
+ )
142
+ }
143
+ )
144
+ }
145
+
146
+ /**
147
+ * Repairs carrier static body ownership.
148
+ * @param {object[] | undefined} placements Scene static body placements.
149
+ * @param {object[]} componentBodies Source component-body rows.
150
+ * @param {object[]} assignments Carrier assignments.
151
+ * @param {object | undefined} board Scene board metadata.
152
+ * @returns {object[]}
153
+ */
154
+ static #repairStaticPlacements(
155
+ placements,
156
+ componentBodies,
157
+ assignments,
158
+ board
159
+ ) {
160
+ return (Array.isArray(placements) ? placements : []).map(
161
+ (placement) => {
162
+ const assignment = assignments.find((candidate) =>
163
+ AltiumScene3dShapeStackOwnerAdapter.#isSameStaticBody(
164
+ placement,
165
+ candidate.base
166
+ )
167
+ )
168
+ if (!assignment) {
169
+ return placement
170
+ }
171
+
172
+ return {
173
+ ...placement,
174
+ designator: String(assignment.owner.designator || ''),
175
+ positionMil:
176
+ AltiumScene3dShapeStackOwnerAdapter.#withOwnerAnchor(
177
+ placement?.positionMil,
178
+ assignment.owner,
179
+ board
180
+ ),
181
+ coLocatedVariantGroupKey: assignment.groupKey
182
+ }
183
+ }
184
+ )
185
+ }
186
+
187
+ /**
188
+ * Re-anchors a repaired static carrier on the owning component position.
189
+ * @param {object | undefined} position Existing render position.
190
+ * @param {{ x?: number, y?: number }} owner Owner component.
191
+ * @param {object | undefined} board Scene board metadata.
192
+ * @returns {{ x: number, y: number, z: number }}
193
+ */
194
+ static #withOwnerAnchor(position, owner, board) {
195
+ return {
196
+ ...(position || {}),
197
+ x: AltiumScene3dShapeStackOwnerAdapter.#round(
198
+ Number(owner?.x || 0) - Number(board?.centerX || 0)
199
+ ),
200
+ y: AltiumScene3dShapeStackOwnerAdapter.#round(
201
+ Number(owner?.y || 0) - Number(board?.centerY || 0)
202
+ )
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Builds carrier-base to owner-component assignments.
208
+ * @param {object[]} componentBodies Source component-body rows.
209
+ * @param {object[]} components Source components.
210
+ * @returns {{ base: object, owner: object, heightMil: number, groupKey: string }[]}
211
+ */
212
+ static #carrierAssignments(componentBodies, components) {
213
+ const bases = componentBodies.filter((body) =>
214
+ AltiumScene3dShapeStackOwnerAdapter.#isCarrierBase(body)
215
+ )
216
+ const timingComponents = components.filter((component) =>
217
+ AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(component)
218
+ )
219
+ const groups =
220
+ AltiumScene3dShapeStackOwnerAdapter.#groupCarrierBases(bases)
221
+
222
+ return groups.flatMap((group) => {
223
+ if (
224
+ AltiumScene3dShapeStackOwnerAdapter.#hasExactNonTimingOwner(
225
+ group.anchor,
226
+ components
227
+ )
228
+ ) {
229
+ return []
230
+ }
231
+
232
+ const owners = timingComponents
233
+ .filter(
234
+ (component) =>
235
+ AltiumScene3dShapeStackOwnerAdapter.#distance(
236
+ group.anchor,
237
+ component
238
+ ) <=
239
+ AltiumScene3dShapeStackOwnerAdapter
240
+ .#BASE_OWNER_TOLERANCE_MIL
241
+ )
242
+ .sort(
243
+ (left, right) =>
244
+ AltiumScene3dShapeStackOwnerAdapter.#ownerRank(right) -
245
+ AltiumScene3dShapeStackOwnerAdapter.#ownerRank(
246
+ left
247
+ ) ||
248
+ String(left?.designator || '').localeCompare(
249
+ String(right?.designator || '')
250
+ )
251
+ )
252
+ if (!owners.length) {
253
+ return []
254
+ }
255
+ const groupKey = AltiumScene3dShapeStackOwnerAdapter.#groupKey(
256
+ group.anchor
257
+ )
258
+
259
+ return group.bases
260
+ .slice()
261
+ .sort(
262
+ (left, right) =>
263
+ Number(
264
+ left?.staticGeometry?.heightMil ??
265
+ left?.overallHeightMil ??
266
+ 0
267
+ ) -
268
+ Number(
269
+ right?.staticGeometry?.heightMil ??
270
+ right?.overallHeightMil ??
271
+ 0
272
+ )
273
+ )
274
+ .map((base, index) => ({
275
+ base,
276
+ owner: owners[Math.min(index, owners.length - 1)],
277
+ groupKey,
278
+ heightMil: Number(
279
+ base?.staticGeometry?.heightMil ??
280
+ base?.overallHeightMil ??
281
+ 0
282
+ )
283
+ }))
284
+ })
285
+ }
286
+
287
+ /**
288
+ * Checks whether one carrier base already sits on a non-timing component.
289
+ * @param {{ x?: number, y?: number }} anchor Carrier base anchor.
290
+ * @param {object[]} components Source components.
291
+ * @returns {boolean}
292
+ */
293
+ static #hasExactNonTimingOwner(anchor, components) {
294
+ const exactOwners = (
295
+ Array.isArray(components) ? components : []
296
+ ).filter(
297
+ (component) =>
298
+ AltiumScene3dShapeStackOwnerAdapter.#distance(
299
+ anchor,
300
+ component
301
+ ) <=
302
+ AltiumScene3dShapeStackOwnerAdapter.#EXACT_OWNER_TOLERANCE_MIL
303
+ )
304
+
305
+ return (
306
+ exactOwners.some(
307
+ (component) =>
308
+ !AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(
309
+ component
310
+ )
311
+ ) &&
312
+ !exactOwners.some((component) =>
313
+ AltiumScene3dShapeStackOwnerAdapter.#isTimingComponent(
314
+ component
315
+ )
316
+ )
317
+ )
318
+ }
319
+
320
+ /**
321
+ * Groups carrier bases that share one source anchor.
322
+ * @param {object[]} bases Candidate carrier bases.
323
+ * @returns {{ anchor: { x: number, y: number }, bases: object[] }[]}
324
+ */
325
+ static #groupCarrierBases(bases) {
326
+ const groups = []
327
+
328
+ bases.forEach((base) => {
329
+ const anchor = AltiumScene3dShapeStackOwnerAdapter.#point(
330
+ base?.positionMil
331
+ )
332
+ const group = groups.find(
333
+ (candidate) =>
334
+ AltiumScene3dShapeStackOwnerAdapter.#distance(
335
+ candidate.anchor,
336
+ anchor
337
+ ) <= 1
338
+ )
339
+ if (group) {
340
+ group.bases.push(base)
341
+ } else {
342
+ groups.push({ anchor, bases: [base] })
343
+ }
344
+ })
345
+
346
+ return groups
347
+ }
348
+
349
+ /**
350
+ * Checks whether one body row describes an authored carrier base.
351
+ * @param {object} componentBody Source component-body row.
352
+ * @returns {boolean}
353
+ */
354
+ static #isCarrierBase(componentBody) {
355
+ const geometry = componentBody?.staticGeometry || {}
356
+ return (
357
+ AltiumScene3dShapeStackOwnerAdapter.#isShapeBasedBody(
358
+ componentBody
359
+ ) &&
360
+ !componentBody?.embedded &&
361
+ String(geometry?.kind || '').toLowerCase() === 'extruded-polygon' &&
362
+ String(geometry?.status || '').toLowerCase() === 'complete' &&
363
+ Number(geometry?.heightMil ?? componentBody?.overallHeightMil) > 0
364
+ )
365
+ }
366
+
367
+ /**
368
+ * Finds a carrier assignment for one positive-height sub-body.
369
+ * @param {object | null} componentBody Source component-body row.
370
+ * @param {object[]} assignments Carrier assignments.
371
+ * @returns {object | null}
372
+ */
373
+ static #assignmentForBody(componentBody, assignments) {
374
+ if (
375
+ !componentBody ||
376
+ !AltiumScene3dShapeStackOwnerAdapter.#isShapeBasedBody(
377
+ componentBody
378
+ ) ||
379
+ Number(componentBody?.standoffHeightMil || 0) <= 0
380
+ ) {
381
+ return null
382
+ }
383
+
384
+ const standoff = Number(componentBody.standoffHeightMil)
385
+ const bodyPosition = AltiumScene3dShapeStackOwnerAdapter.#point(
386
+ componentBody.positionMil
387
+ )
388
+
389
+ return (
390
+ assignments
391
+ .map((assignment) => ({
392
+ assignment,
393
+ heightError: Math.abs(
394
+ Number(assignment.heightMil || 0) - standoff
395
+ ),
396
+ distance: AltiumScene3dShapeStackOwnerAdapter.#distance(
397
+ bodyPosition,
398
+ assignment.owner
399
+ )
400
+ }))
401
+ .filter(
402
+ (candidate) =>
403
+ candidate.heightError <=
404
+ AltiumScene3dShapeStackOwnerAdapter
405
+ .#HEIGHT_TOLERANCE_MIL &&
406
+ candidate.distance <=
407
+ AltiumScene3dShapeStackOwnerAdapter
408
+ .#STACK_BODY_RADIUS_MIL
409
+ )
410
+ .sort(
411
+ (left, right) =>
412
+ left.heightError - right.heightError ||
413
+ left.distance - right.distance
414
+ )[0]?.assignment || null
415
+ )
416
+ }
417
+
418
+ /**
419
+ * Applies one owner component to an external placement.
420
+ * @param {object} placement External placement.
421
+ * @param {object} componentBody Source component-body row.
422
+ * @param {{ owner: object, groupKey: string }} assignment Stack assignment.
423
+ * @param {object | undefined} board Scene board metadata.
424
+ * @returns {object}
425
+ */
426
+ static #withOwner(placement, componentBody, assignment, board) {
427
+ const owner = assignment.owner
428
+ const mountSide =
429
+ AltiumScene3dShapeStackOwnerAdapter.#mountSide(owner) ||
430
+ placement.mountSide
431
+ const rotationDeg = AltiumScene3dShapeStackOwnerAdapter.#normalizeAngle(
432
+ owner.rotation
433
+ )
434
+ const ownerOffset = {
435
+ x:
436
+ Number(componentBody?.positionMil?.x || 0) -
437
+ Number(owner?.x || 0),
438
+ y:
439
+ Number(componentBody?.positionMil?.y || 0) -
440
+ Number(owner?.y || 0)
441
+ }
442
+ const standoff = Number(componentBody?.standoffHeightMil || 0)
443
+ const renderableOffset =
444
+ AltiumScene3dShapeStackOwnerAdapter.#renderableOffset(
445
+ { mountSide, rotationDeg },
446
+ ownerOffset,
447
+ standoff
448
+ )
449
+
450
+ return {
451
+ ...placement,
452
+ designator: String(owner?.designator || placement.designator),
453
+ mountSide,
454
+ rotationDeg,
455
+ positionMil: {
456
+ ...(placement.positionMil || {}),
457
+ x: Number(owner?.x || 0) - Number(board?.centerX || 0),
458
+ y: Number(owner?.y || 0) - Number(board?.centerY || 0),
459
+ z: AltiumScene3dShapeStackOwnerAdapter.#faceZ(mountSide, board)
460
+ },
461
+ projection: {
462
+ ...(placement.projection || {}),
463
+ source: 'authored-shape-stack',
464
+ reason: 'Altium shape-based sub-body is seated on an authored carrier stack.'
465
+ },
466
+ coLocatedVariantGroupKey: assignment.groupKey,
467
+ modelTransform: {
468
+ ...(placement.modelTransform || {}),
469
+ dzMil: standoff,
470
+ offsetMil: renderableOffset,
471
+ ownerAnchorOffsetMil: ownerOffset
472
+ }
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Converts a board-space owner offset into render local coordinates.
478
+ * @param {{ mountSide?: string, rotationDeg?: number }} placement Placement.
479
+ * @param {{ x: number, y: number }} offset Board-space offset.
480
+ * @param {number} standoff Vertical stack offset.
481
+ * @returns {{ x: number, y: number, z: number }}
482
+ */
483
+ static #renderableOffset(placement, offset, standoff) {
484
+ const rotationRad =
485
+ (-AltiumScene3dShapeStackOwnerAdapter.#normalizeAngle(
486
+ placement.rotationDeg
487
+ ) *
488
+ Math.PI) /
489
+ 180
490
+ const cos = Math.cos(rotationRad)
491
+ const sin = Math.sin(rotationRad)
492
+ const sourceY =
493
+ String(placement?.mountSide || '').toLowerCase() === 'bottom'
494
+ ? Number(offset.y || 0)
495
+ : -Number(offset.y || 0)
496
+ const x = Number(offset.x || 0) * cos - sourceY * sin
497
+ const y = Number(offset.x || 0) * sin + sourceY * cos
498
+
499
+ return {
500
+ x: AltiumScene3dShapeStackOwnerAdapter.#round(x),
501
+ y:
502
+ String(placement?.mountSide || '').toLowerCase() === 'bottom'
503
+ ? AltiumScene3dShapeStackOwnerAdapter.#round(-y)
504
+ : AltiumScene3dShapeStackOwnerAdapter.#round(y),
505
+ z: AltiumScene3dShapeStackOwnerAdapter.#round(standoff)
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Checks whether one static placement came from one carrier base row.
511
+ * @param {object} placement Static placement.
512
+ * @param {object} base Source base row.
513
+ * @returns {boolean}
514
+ */
515
+ static #isSameStaticBody(placement, base) {
516
+ return (
517
+ AltiumScene3dShapeStackOwnerAdapter.#distance(
518
+ placement?.bodyPositionMil,
519
+ base?.positionMil
520
+ ) <= 0.01 &&
521
+ Math.abs(
522
+ Number(placement?.geometry?.heightMil || 0) -
523
+ Number(
524
+ base?.staticGeometry?.heightMil ??
525
+ base?.overallHeightMil ??
526
+ 0
527
+ )
528
+ ) <= AltiumScene3dShapeStackOwnerAdapter.#HEIGHT_TOLERANCE_MIL
529
+ )
530
+ }
531
+
532
+ /**
533
+ * Resolves the source component body row for one external placement.
534
+ * @param {object} placement External placement.
535
+ * @param {object[]} componentBodies Source body rows.
536
+ * @param {Set<object>} usedComponentBodies Already consumed body rows.
537
+ * @returns {object | null}
538
+ */
539
+ static #resolveComponentBody(
540
+ placement,
541
+ componentBodies,
542
+ usedComponentBodies
543
+ ) {
544
+ return (
545
+ componentBodies
546
+ .filter(
547
+ (componentBody) =>
548
+ !usedComponentBodies.has(componentBody) &&
549
+ !AltiumScene3dShapeStackOwnerAdapter.#isCarrierBase(
550
+ componentBody
551
+ )
552
+ )
553
+ .map((componentBody) => ({
554
+ componentBody,
555
+ distance: AltiumScene3dShapeStackOwnerAdapter.#distance(
556
+ placement?.bodyPositionMil,
557
+ componentBody?.positionMil
558
+ ),
559
+ standoffError:
560
+ AltiumScene3dShapeStackOwnerAdapter.#standoffError(
561
+ placement,
562
+ componentBody
563
+ ),
564
+ score: AltiumScene3dShapeStackOwnerAdapter.#identityScore(
565
+ placement,
566
+ componentBody
567
+ )
568
+ }))
569
+ .filter((candidate) => candidate.distance <= 0.01)
570
+ .sort(
571
+ (left, right) =>
572
+ right.score - left.score ||
573
+ left.standoffError - right.standoffError ||
574
+ left.distance - right.distance
575
+ )[0]?.componentBody || null
576
+ )
577
+ }
578
+
579
+ /**
580
+ * Scores whether a source body row matches an already-known stack height.
581
+ * @param {object} placement External placement.
582
+ * @param {object} componentBody Source body row.
583
+ * @returns {number}
584
+ */
585
+ static #standoffError(placement, componentBody) {
586
+ const placementHeight = Number(
587
+ placement?.modelTransform?.offsetMil?.z ??
588
+ placement?.modelTransform?.dzMil
589
+ )
590
+ const bodyStandoff = Number(componentBody?.standoffHeightMil)
591
+ if (
592
+ !Number.isFinite(placementHeight) ||
593
+ !Number.isFinite(bodyStandoff) ||
594
+ placementHeight <= 0
595
+ ) {
596
+ return 0
597
+ }
598
+
599
+ return Math.abs(placementHeight - bodyStandoff)
600
+ }
601
+
602
+ /**
603
+ * Scores whether a source body row belongs to one placement.
604
+ * @param {object} placement External placement.
605
+ * @param {object} componentBody Source body row.
606
+ * @returns {number}
607
+ */
608
+ static #identityScore(placement, componentBody) {
609
+ const placementText = AltiumScene3dShapeStackOwnerAdapter.#identityText(
610
+ [placement?.externalModel?.name, placement?.designator]
611
+ )
612
+ const bodyText = AltiumScene3dShapeStackOwnerAdapter.#identityText([
613
+ componentBody?.name,
614
+ componentBody?.identifier
615
+ ])
616
+
617
+ return placementText && bodyText && placementText.includes(bodyText)
618
+ ? bodyText.length
619
+ : 0
620
+ }
621
+
622
+ /**
623
+ * Checks whether a component is a timing-package owner.
624
+ * @param {object} component Source component.
625
+ * @returns {boolean}
626
+ */
627
+ static #isTimingComponent(component) {
628
+ const designator = String(component?.designator || '').trim()
629
+
630
+ return (
631
+ TIMING_DESIGNATOR_PATTERN.test(designator) ||
632
+ TIMING_PACKAGE_PATTERN.test(
633
+ [
634
+ component?.pattern,
635
+ component?.source,
636
+ component?.description,
637
+ component?.provenance?.footprintDescription,
638
+ ...Object.values(component?.parameters || {})
639
+ ]
640
+ .map((value) => String(value || ''))
641
+ .join(' ')
642
+ )
643
+ )
644
+ }
645
+
646
+ /**
647
+ * Scores how strongly a component looks like the fitted stack owner.
648
+ * @param {object} component Source component.
649
+ * @returns {number}
650
+ */
651
+ static #ownerRank(component) {
652
+ const parameters = Object.values(component?.parameters || {}).filter(
653
+ (value) => String(value || '').trim()
654
+ )
655
+ return (
656
+ parameters.length * 10 +
657
+ (TIMING_DESIGNATOR_PATTERN.test(component?.designator) ? 5 : 0) +
658
+ (TIMING_PACKAGE_PATTERN.test(
659
+ [
660
+ component?.pattern,
661
+ component?.source,
662
+ component?.description,
663
+ component?.provenance?.footprintDescription
664
+ ]
665
+ .map((value) => String(value || ''))
666
+ .join(' ')
667
+ )
668
+ ? 3
669
+ : 0)
670
+ )
671
+ }
672
+
673
+ /**
674
+ * Checks whether one body row came from shape-based component metadata.
675
+ * @param {object | null | undefined} componentBody Source body row.
676
+ * @returns {boolean}
677
+ */
678
+ static #isShapeBasedBody(componentBody) {
679
+ return String(componentBody?.sourceStream || '').includes(
680
+ 'ShapeBasedComponentBodies'
681
+ )
682
+ }
683
+
684
+ /**
685
+ * Resolves one component's mount side.
686
+ * @param {object} component Source component.
687
+ * @returns {'top' | 'bottom'}
688
+ */
689
+ static #mountSide(component) {
690
+ const layer = String(component?.layer || '').toUpperCase()
691
+ return layer.includes('BOTTOM') || layer === 'BOT' ? 'bottom' : 'top'
692
+ }
693
+
694
+ /**
695
+ * Resolves one board face Z coordinate.
696
+ * @param {string} mountSide Mount side.
697
+ * @param {object | undefined} board Scene board metadata.
698
+ * @returns {number}
699
+ */
700
+ static #faceZ(mountSide, board) {
701
+ const thickness = Number(board?.thicknessMil) || 63
702
+ return String(mountSide || '').toLowerCase() === 'bottom'
703
+ ? -thickness / 2
704
+ : thickness / 2
705
+ }
706
+
707
+ /**
708
+ * Normalizes an angle into [0, 360).
709
+ * @param {number} angle Candidate angle.
710
+ * @returns {number}
711
+ */
712
+ static #normalizeAngle(angle) {
713
+ const normalized = Number(angle || 0) % 360
714
+ return normalized < 0 ? normalized + 360 : normalized
715
+ }
716
+
717
+ /**
718
+ * Measures the XY distance between two board points.
719
+ * @param {object | undefined} first First point.
720
+ * @param {object | undefined} second Second point.
721
+ * @returns {number}
722
+ */
723
+ static #distance(first, second) {
724
+ return Math.hypot(
725
+ Number(first?.x || 0) - Number(second?.x || 0),
726
+ Number(first?.y || 0) - Number(second?.y || 0)
727
+ )
728
+ }
729
+
730
+ /**
731
+ * Normalizes one point.
732
+ * @param {object | undefined} point Source point.
733
+ * @returns {{ x: number, y: number }}
734
+ */
735
+ static #point(point) {
736
+ return {
737
+ x: Number(point?.x || 0),
738
+ y: Number(point?.y || 0)
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Builds a stable key for co-located authored stack variants.
744
+ * @param {{ x?: number, y?: number }} point Stack anchor.
745
+ * @returns {string}
746
+ */
747
+ static #groupKey(point) {
748
+ return [
749
+ 'altium-shape-stack',
750
+ AltiumScene3dShapeStackOwnerAdapter.#round(point?.x || 0),
751
+ AltiumScene3dShapeStackOwnerAdapter.#round(point?.y || 0)
752
+ ].join(':')
753
+ }
754
+
755
+ /**
756
+ * Builds normalized identity text.
757
+ * @param {unknown[]} values Identity values.
758
+ * @returns {string}
759
+ */
760
+ static #identityText(values) {
761
+ return values
762
+ .map((value) =>
763
+ String(value || '')
764
+ .replace(/\.[^.]+$/, '')
765
+ .trim()
766
+ .toLowerCase()
767
+ .replace(/[^a-z0-9]+/gu, '')
768
+ )
769
+ .filter(Boolean)
770
+ .join(' ')
771
+ }
772
+
773
+ /**
774
+ * Rounds one mil value for stable scene output.
775
+ * @param {number} value Candidate value.
776
+ * @returns {number}
777
+ */
778
+ static #round(value) {
779
+ const rounded = Math.round(Number(value) * 10000) / 10000
780
+ return Object.is(rounded, -0) ? 0 : rounded
781
+ }
782
+ }