altium-toolkit 1.1.35 → 1.1.36

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,899 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { PcbScene3dStaticBodySymmetricOwnerPromotion } from './PcbScene3dStaticBodySymmetricOwnerPromotion.mjs'
6
+
7
+ /**
8
+ * Promotes compact static body fragments to one clear touching owner.
9
+ */
10
+ export class PcbScene3dStaticBodyOwnerPromotion {
11
+ static #NEAREST_OWNER_DISTANCE_MIL = 20
12
+ static #NEAREST_OWNER_AMBIGUITY_MARGIN_MIL = 2
13
+ static #CLUSTER_CENTER_MAX_SPAN_MIL = 45
14
+ static #GENERIC_SUBPART_OWNER_MAX_SPAN_MIL = 80
15
+ static #GENERIC_SUBPART_TOKENS = new Set(['plastic'])
16
+
17
+ /**
18
+ * Promotes generic compact fragments that physically touch one clear owner.
19
+ * @param {{ placement: object, matchedComponent: object | null }[]} placementRows Mutable placement rows.
20
+ * @param {{ designator?: string, x?: number, y?: number, layer?: string }[]} [components] PCB components.
21
+ * @param {{ centerX?: number, centerY?: number } | null} [board] Board context.
22
+ */
23
+ static promote(placementRows, components = [], board = null) {
24
+ PcbScene3dStaticBodyOwnerPromotion.#promoteNearestOwners(
25
+ placementRows,
26
+ components
27
+ )
28
+
29
+ PcbScene3dStaticBodySymmetricOwnerPromotion.promote(
30
+ placementRows,
31
+ components
32
+ )
33
+
34
+ const clusters =
35
+ PcbScene3dStaticBodyOwnerPromotion.#clusters(placementRows)
36
+
37
+ PcbScene3dStaticBodyOwnerPromotion.#promoteClusterCenterOwners(
38
+ placementRows,
39
+ clusters,
40
+ components,
41
+ board
42
+ )
43
+
44
+ clusters.forEach((cluster) => {
45
+ const ownerRow = PcbScene3dStaticBodyOwnerPromotion.#uniqueOwnerRow(
46
+ placementRows,
47
+ cluster.indexes
48
+ )
49
+ if (
50
+ !ownerRow ||
51
+ !cluster.indexes.every((index) =>
52
+ PcbScene3dStaticBodyOwnerPromotion.#canPromote(
53
+ placementRows[index]
54
+ )
55
+ )
56
+ ) {
57
+ return
58
+ }
59
+
60
+ cluster.indexes.forEach((index) => {
61
+ placementRows[index].matchedComponent =
62
+ ownerRow.matchedComponent
63
+ placementRows[index].placement =
64
+ PcbScene3dStaticBodyOwnerPromotion.#withOwner(
65
+ placementRows[index].placement,
66
+ ownerRow.matchedComponent
67
+ )
68
+ })
69
+ })
70
+ }
71
+
72
+ /**
73
+ * Reassigns compact clusters to the component nearest the cluster center.
74
+ * @param {{ placement: object, matchedComponent: object | null }[]} placementRows Mutable placement rows.
75
+ * @param {{ indexes: number[], bounds: object }[]} clusters Touching clusters.
76
+ * @param {{ designator?: string, x?: number, y?: number, layer?: string }[]} components PCB components.
77
+ * @param {{ centerX?: number, centerY?: number } | null} board Board context.
78
+ */
79
+ static #promoteClusterCenterOwners(
80
+ placementRows,
81
+ clusters,
82
+ components,
83
+ board
84
+ ) {
85
+ clusters
86
+ .flatMap((cluster) =>
87
+ PcbScene3dStaticBodyOwnerPromotion.#orientationSubclusters(
88
+ cluster,
89
+ placementRows
90
+ )
91
+ )
92
+ .forEach((cluster) => {
93
+ if (
94
+ cluster.indexes.length <= 1 ||
95
+ PcbScene3dStaticBodyOwnerPromotion.#boundsMaxSpan(
96
+ cluster.bounds
97
+ ) >
98
+ PcbScene3dStaticBodyOwnerPromotion
99
+ .#CLUSTER_CENTER_MAX_SPAN_MIL ||
100
+ !cluster.indexes.every((index) =>
101
+ PcbScene3dStaticBodyOwnerPromotion.#isCompactRow(
102
+ placementRows[index]
103
+ )
104
+ )
105
+ ) {
106
+ return
107
+ }
108
+
109
+ const owner =
110
+ PcbScene3dStaticBodyOwnerPromotion.#nearestOwnerToSourcePoint(
111
+ PcbScene3dStaticBodyOwnerPromotion.#clusterSourceCenter(
112
+ cluster,
113
+ placementRows,
114
+ board
115
+ ),
116
+ PcbScene3dStaticBodyOwnerPromotion.#componentsForClusterSide(
117
+ components,
118
+ cluster.ownerSide
119
+ )
120
+ )
121
+ if (!owner) {
122
+ return
123
+ }
124
+
125
+ cluster.indexes.forEach((index) => {
126
+ placementRows[index].matchedComponent = owner
127
+ placementRows[index].placement =
128
+ PcbScene3dStaticBodyOwnerPromotion.#withOwner(
129
+ placementRows[index].placement,
130
+ owner
131
+ )
132
+ })
133
+ })
134
+ }
135
+
136
+ /**
137
+ * Splits one touching cluster into dominant-orientation subclusters.
138
+ * @param {{ indexes: number[] }} cluster Touching cluster.
139
+ * @param {{ placement: object }[]} placementRows Placement rows.
140
+ * @returns {{ indexes: number[], bounds: object }[]}
141
+ */
142
+ static #orientationSubclusters(cluster, placementRows) {
143
+ const buckets = new Map()
144
+
145
+ cluster.indexes.forEach((index) => {
146
+ const key = PcbScene3dStaticBodyOwnerPromotion.#orientationBucket(
147
+ placementRows[index]?.placement
148
+ )
149
+ if (!buckets.has(key)) {
150
+ buckets.set(key, [])
151
+ }
152
+ buckets.get(key)?.push(index)
153
+ })
154
+
155
+ return [...buckets.entries()].map(([key, indexes]) => ({
156
+ indexes,
157
+ ownerSide:
158
+ PcbScene3dStaticBodyOwnerPromotion.#ownerSideFromBucketKey(key),
159
+ bounds: PcbScene3dStaticBodyOwnerPromotion.#boundsForIndexes(
160
+ placementRows,
161
+ indexes
162
+ )
163
+ }))
164
+ }
165
+
166
+ /**
167
+ * Filters candidate components by a side-specific cluster key.
168
+ * @param {object[]} components PCB components.
169
+ * @param {string} ownerSide Required owner side.
170
+ * @returns {object[]}
171
+ */
172
+ static #componentsForClusterSide(components, ownerSide) {
173
+ const side = String(ownerSide || '').trim()
174
+
175
+ return side
176
+ ? (Array.isArray(components) ? components : []).filter(
177
+ (component) =>
178
+ PcbScene3dStaticBodyOwnerPromotion.#componentMountSide(
179
+ component
180
+ ) === side
181
+ )
182
+ : components
183
+ }
184
+
185
+ /**
186
+ * Resolves a side constraint from an orientation bucket key.
187
+ * @param {string} key Orientation bucket key.
188
+ * @returns {string}
189
+ */
190
+ static #ownerSideFromBucketKey(key) {
191
+ const [orientation, side] = String(key || '').split(':')
192
+
193
+ return orientation === 'axis' || orientation === 'unknown'
194
+ ? String(side || '').trim()
195
+ : ''
196
+ }
197
+
198
+ /**
199
+ * Builds merged horizontal bounds for selected placement rows.
200
+ * @param {{ placement: object }[]} placementRows Placement rows.
201
+ * @param {number[]} indexes Row indexes.
202
+ * @returns {object}
203
+ */
204
+ static #boundsForIndexes(placementRows, indexes) {
205
+ return indexes
206
+ .map((index) =>
207
+ PcbScene3dStaticBodyOwnerPromotion.#bounds(
208
+ placementRows[index]?.placement
209
+ )
210
+ )
211
+ .reduce((bounds, candidate) =>
212
+ bounds
213
+ ? PcbScene3dStaticBodyOwnerPromotion.#merge(
214
+ bounds,
215
+ candidate
216
+ )
217
+ : candidate
218
+ )
219
+ }
220
+
221
+ /**
222
+ * Resolves a broad orientation bucket for compact polygon ownership.
223
+ * @param {{ geometry?: object } | null} placement Static placement.
224
+ * @returns {string}
225
+ */
226
+ static #orientationBucket(placement) {
227
+ const angle = PcbScene3dStaticBodyOwnerPromotion.#dominantEdgeAngle(
228
+ placement?.geometry?.verticesMil
229
+ )
230
+ const side = String(placement?.mountSide || '')
231
+ .trim()
232
+ .toLowerCase()
233
+ if (!Number.isFinite(angle)) {
234
+ return 'unknown:' + side
235
+ }
236
+
237
+ const normalized = ((angle % 90) + 90) % 90
238
+ return normalized > 22.5 && normalized < 67.5
239
+ ? 'diagonal'
240
+ : 'axis:' + side
241
+ }
242
+
243
+ /**
244
+ * Resolves the longest polygon-edge angle.
245
+ * @param {{ x?: number, y?: number }[] | undefined} vertices Vertices.
246
+ * @returns {number | null}
247
+ */
248
+ static #dominantEdgeAngle(vertices) {
249
+ const points = Array.isArray(vertices) ? vertices : []
250
+ if (points.length < 2) {
251
+ return null
252
+ }
253
+
254
+ let bestEdge = null
255
+ points.forEach((point, index) => {
256
+ const next = points[(index + 1) % points.length]
257
+ const dx = Number(next?.x || 0) - Number(point?.x || 0)
258
+ const dy = Number(next?.y || 0) - Number(point?.y || 0)
259
+ const length = Math.hypot(dx, dy)
260
+ if (!bestEdge || length > bestEdge.length) {
261
+ bestEdge = {
262
+ length,
263
+ angle: Math.atan2(dy, dx) * (180 / Math.PI)
264
+ }
265
+ }
266
+ })
267
+
268
+ return bestEdge ? ((bestEdge.angle % 180) + 180) % 180 : null
269
+ }
270
+
271
+ /**
272
+ * Promotes compact generic fragments that sit directly on one component.
273
+ * @param {{ placement: object, matchedComponent: object | null }[]} placementRows Mutable placement rows.
274
+ * @param {{ designator?: string, x?: number, y?: number, layer?: string }[]} components PCB components.
275
+ */
276
+ static #promoteNearestOwners(placementRows, components) {
277
+ placementRows.forEach((row) => {
278
+ if (
279
+ !PcbScene3dStaticBodyOwnerPromotion.#canPromoteNearestOwner(row)
280
+ ) {
281
+ return
282
+ }
283
+
284
+ const owner =
285
+ PcbScene3dStaticBodyOwnerPromotion.#nearestOwnerForPlacement(
286
+ row.placement,
287
+ components
288
+ )
289
+ if (!owner) {
290
+ return
291
+ }
292
+
293
+ row.matchedComponent = owner
294
+ row.placement = PcbScene3dStaticBodyOwnerPromotion.#withOwner(
295
+ row.placement,
296
+ owner
297
+ )
298
+ })
299
+ }
300
+
301
+ /**
302
+ * Resolves one owner for a compact placement using distance and offset axis.
303
+ * @param {{ bodyPositionMil?: { x?: number, y?: number }, mountSide?: string, mountSideLocked?: boolean }} placement Static placement.
304
+ * @param {{ designator?: string, x?: number, y?: number, rotation?: number, layer?: string }[]} components PCB components.
305
+ * @returns {object | null}
306
+ */
307
+ static #nearestOwnerForPlacement(placement, components) {
308
+ const position =
309
+ PcbScene3dStaticBodyOwnerPromotion.#sourceBodyPosition(placement)
310
+ if (!position) {
311
+ return null
312
+ }
313
+ const lockedPlacementSide = placement?.mountSideLocked
314
+ ? PcbScene3dStaticBodyOwnerPromotion.#normalizeMountSide(
315
+ placement?.mountSide
316
+ )
317
+ : null
318
+
319
+ const candidates = (Array.isArray(components) ? components : [])
320
+ .map((component) => ({
321
+ component,
322
+ distance: Math.hypot(
323
+ Number(component?.x || 0) - position.x,
324
+ Number(component?.y || 0) - position.y
325
+ )
326
+ }))
327
+ .filter(
328
+ ({ component, distance }) =>
329
+ String(component?.designator || '').trim() &&
330
+ Number.isFinite(distance) &&
331
+ distance <=
332
+ PcbScene3dStaticBodyOwnerPromotion
333
+ .#NEAREST_OWNER_DISTANCE_MIL &&
334
+ (!lockedPlacementSide ||
335
+ PcbScene3dStaticBodyOwnerPromotion.#componentMountSide(
336
+ component
337
+ ) === lockedPlacementSide) &&
338
+ PcbScene3dStaticBodyOwnerPromotion.#componentOffsetCompatible(
339
+ position,
340
+ component,
341
+ distance
342
+ )
343
+ )
344
+ .sort((left, right) => left.distance - right.distance)
345
+
346
+ return PcbScene3dStaticBodyOwnerPromotion.#unambiguousNearest(
347
+ candidates
348
+ )
349
+ }
350
+
351
+ /**
352
+ * Resolves one unambiguous nearby owner for a source-space point.
353
+ * @param {{ x: number, y: number } | null} position Source-space point.
354
+ * @param {{ designator?: string, x?: number, y?: number }[]} components PCB components.
355
+ * @returns {object | null}
356
+ */
357
+ static #nearestOwnerToSourcePoint(position, components) {
358
+ if (!position) {
359
+ return null
360
+ }
361
+
362
+ const candidates = (Array.isArray(components) ? components : [])
363
+ .map((component) => ({
364
+ component,
365
+ distance: Math.hypot(
366
+ Number(component?.x || 0) - position.x,
367
+ Number(component?.y || 0) - position.y
368
+ )
369
+ }))
370
+ .filter(
371
+ ({ component, distance }) =>
372
+ String(component?.designator || '').trim() &&
373
+ Number.isFinite(distance) &&
374
+ distance <=
375
+ PcbScene3dStaticBodyOwnerPromotion
376
+ .#NEAREST_OWNER_DISTANCE_MIL
377
+ )
378
+ .sort((left, right) => left.distance - right.distance)
379
+
380
+ return PcbScene3dStaticBodyOwnerPromotion.#unambiguousNearest(
381
+ candidates
382
+ )
383
+ }
384
+
385
+ /**
386
+ * Resolves an unambiguous nearest component from distance candidates.
387
+ * @param {{ component: object, distance: number }[]} candidates Candidates.
388
+ * @returns {object | null}
389
+ */
390
+ static #unambiguousNearest(candidates) {
391
+ const nearest = candidates[0]
392
+ if (!nearest) {
393
+ return null
394
+ }
395
+
396
+ const nextNearest = candidates[1]
397
+ const ambiguityMargin =
398
+ nearest.distance <= 1
399
+ ? 0.25
400
+ : PcbScene3dStaticBodyOwnerPromotion
401
+ .#NEAREST_OWNER_AMBIGUITY_MARGIN_MIL
402
+ if (
403
+ nextNearest &&
404
+ nextNearest.distance - nearest.distance <= ambiguityMargin
405
+ ) {
406
+ return null
407
+ }
408
+
409
+ return nearest.component
410
+ }
411
+
412
+ /**
413
+ * Checks whether a fragment offset aligns with a component's package axis.
414
+ * @param {{ x: number, y: number }} position Fragment source position.
415
+ * @param {{ x?: number, y?: number, rotation?: number }} component Candidate component.
416
+ * @param {number} distanceMil Fragment/component distance.
417
+ * @returns {boolean}
418
+ */
419
+ static #componentOffsetCompatible(position, component, distanceMil) {
420
+ if (Number(distanceMil || 0) <= 1) {
421
+ return true
422
+ }
423
+
424
+ const dx = position.x - Number(component?.x || 0)
425
+ const dy = position.y - Number(component?.y || 0)
426
+ const offsetAngle = Math.atan2(dy, dx) * (180 / Math.PI)
427
+ const delta = PcbScene3dStaticBodyOwnerPromotion.#axisAngleDifference(
428
+ offsetAngle,
429
+ Number(component?.rotation || 0)
430
+ )
431
+
432
+ return delta <= 30
433
+ }
434
+
435
+ /**
436
+ * Resolves the acute difference between two 180-degree axes.
437
+ * @param {number} leftAngle First angle in degrees.
438
+ * @param {number} rightAngle Second angle in degrees.
439
+ * @returns {number}
440
+ */
441
+ static #axisAngleDifference(leftAngle, rightAngle) {
442
+ const normalized = (((leftAngle - rightAngle) % 180) + 180) % 180
443
+
444
+ return Math.min(normalized, 180 - normalized)
445
+ }
446
+
447
+ /**
448
+ * Resolves a touching cluster center in source-space coordinates.
449
+ * @param {{ indexes: number[], bounds: { minX: number, minY: number, maxX: number, maxY: number } }} cluster Touching cluster.
450
+ * @param {{ placement: object }[]} placementRows Placement rows.
451
+ * @param {{ centerX?: number, centerY?: number } | null} board Board context.
452
+ * @returns {{ x: number, y: number } | null}
453
+ */
454
+ static #clusterSourceCenter(cluster, placementRows, board) {
455
+ const center = PcbScene3dStaticBodyOwnerPromotion.#boundsCenter(
456
+ cluster.bounds
457
+ )
458
+ const boardCenter = PcbScene3dStaticBodyOwnerPromotion.#boardCenter(
459
+ board,
460
+ placementRows[cluster.indexes[0]]?.placement
461
+ )
462
+ if (!center || !boardCenter) {
463
+ return null
464
+ }
465
+
466
+ return {
467
+ x: center.x + boardCenter.x,
468
+ y: center.y + boardCenter.y
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Resolves board center from explicit board data or one placement row.
474
+ * @param {{ centerX?: number, centerY?: number } | null} board Board context.
475
+ * @param {{ positionMil?: { x?: number, y?: number }, bodyPositionMil?: { x?: number, y?: number } } | null} placement Placement row.
476
+ * @returns {{ x: number, y: number } | null}
477
+ */
478
+ static #boardCenter(board, placement) {
479
+ const boardX = Number(board?.centerX)
480
+ const boardY = Number(board?.centerY)
481
+ if (Number.isFinite(boardX) && Number.isFinite(boardY)) {
482
+ return { x: boardX, y: boardY }
483
+ }
484
+
485
+ const bodyX = Number(placement?.bodyPositionMil?.x)
486
+ const bodyY = Number(placement?.bodyPositionMil?.y)
487
+ const positionX = Number(placement?.positionMil?.x)
488
+ const positionY = Number(placement?.positionMil?.y)
489
+
490
+ return Number.isFinite(bodyX) &&
491
+ Number.isFinite(bodyY) &&
492
+ Number.isFinite(positionX) &&
493
+ Number.isFinite(positionY)
494
+ ? { x: bodyX - positionX, y: bodyY - positionY }
495
+ : null
496
+ }
497
+
498
+ /**
499
+ * Resolves the center of horizontal bounds.
500
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} bounds Bounds.
501
+ * @returns {{ x: number, y: number } | null}
502
+ */
503
+ static #boundsCenter(bounds) {
504
+ return bounds
505
+ ? {
506
+ x: (bounds.minX + bounds.maxX) / 2,
507
+ y: (bounds.minY + bounds.maxY) / 2
508
+ }
509
+ : null
510
+ }
511
+
512
+ /**
513
+ * Resolves the largest span of one bounds record.
514
+ * @param {{ minX?: number, minY?: number, maxX?: number, maxY?: number } | null} bounds Bounds.
515
+ * @returns {number}
516
+ */
517
+ static #boundsMaxSpan(bounds) {
518
+ if (!bounds) {
519
+ return Infinity
520
+ }
521
+
522
+ return Math.max(
523
+ Number(bounds.maxX || 0) - Number(bounds.minX || 0),
524
+ Number(bounds.maxY || 0) - Number(bounds.minY || 0)
525
+ )
526
+ }
527
+
528
+ /**
529
+ * Resolves the source-space body anchor used for owner proximity.
530
+ * @param {{ bodyPositionMil?: { x?: number, y?: number } }} placement Static placement.
531
+ * @returns {{ x: number, y: number } | null}
532
+ */
533
+ static #sourceBodyPosition(placement) {
534
+ const x = Number(placement?.bodyPositionMil?.x)
535
+ const y = Number(placement?.bodyPositionMil?.y)
536
+
537
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
538
+ }
539
+
540
+ /**
541
+ * Resolves the board side from one component layer.
542
+ * @param {{ layer?: string }} component PCB component.
543
+ * @returns {'top' | 'bottom'}
544
+ */
545
+ static #componentMountSide(component) {
546
+ return String(component?.layer || '')
547
+ .trim()
548
+ .toLowerCase()
549
+ .includes('bottom')
550
+ ? 'bottom'
551
+ : 'top'
552
+ }
553
+
554
+ /**
555
+ * Builds touching placement clusters across source identities and sides.
556
+ * @param {{ placement: object }[]} placementRows Placement rows.
557
+ * @returns {{ indexes: number[], bounds: { minX: number, minY: number, maxX: number, maxY: number } }[]}
558
+ */
559
+ static #clusters(placementRows) {
560
+ const clusters = []
561
+
562
+ placementRows.forEach((row, index) => {
563
+ const bounds = PcbScene3dStaticBodyOwnerPromotion.#bounds(
564
+ row.placement
565
+ )
566
+ const touching = clusters.filter((cluster) =>
567
+ PcbScene3dStaticBodyOwnerPromotion.#touches(
568
+ cluster.bounds,
569
+ bounds,
570
+ 2
571
+ )
572
+ )
573
+
574
+ if (!touching.length) {
575
+ clusters.push({ indexes: [index], bounds })
576
+ return
577
+ }
578
+
579
+ const target = touching[0]
580
+ target.indexes.push(index)
581
+ target.bounds = PcbScene3dStaticBodyOwnerPromotion.#merge(
582
+ target.bounds,
583
+ bounds
584
+ )
585
+ touching.slice(1).forEach((cluster) => {
586
+ target.indexes.push(...cluster.indexes)
587
+ target.bounds = PcbScene3dStaticBodyOwnerPromotion.#merge(
588
+ target.bounds,
589
+ cluster.bounds
590
+ )
591
+ clusters.splice(clusters.indexOf(cluster), 1)
592
+ })
593
+ })
594
+
595
+ return clusters
596
+ }
597
+
598
+ /**
599
+ * Resolves one unambiguous owner row for a touching cluster.
600
+ * @param {{ placement: object, matchedComponent: object | null }[]} placementRows Placement rows.
601
+ * @param {number[]} indexes Cluster indexes.
602
+ * @returns {{ placement: object, matchedComponent: object } | null}
603
+ */
604
+ static #uniqueOwnerRow(placementRows, indexes) {
605
+ const owners = indexes
606
+ .map((index) => placementRows[index])
607
+ .filter((row) => row.matchedComponent)
608
+ const designators = new Set(
609
+ owners.map((row) =>
610
+ String(row.matchedComponent?.designator || '').trim()
611
+ )
612
+ )
613
+
614
+ return designators.size === 1 ? owners[0] : null
615
+ }
616
+
617
+ /**
618
+ * Checks whether one row may inherit a touching cluster owner.
619
+ * @param {{ placement: object, matchedComponent: object | null }} row Placement row.
620
+ * @returns {boolean}
621
+ */
622
+ static #canPromote(row) {
623
+ if (row.matchedComponent) {
624
+ return true
625
+ }
626
+
627
+ return (
628
+ PcbScene3dStaticBodyOwnerPromotion.#isGenericDesignator(
629
+ row.placement
630
+ ) &&
631
+ PcbScene3dStaticBodyOwnerPromotion.#maxSpan(
632
+ row.placement?.geometry
633
+ ) <= 40
634
+ )
635
+ }
636
+
637
+ /**
638
+ * Checks whether one row may claim the nearest exact physical owner.
639
+ * @param {{ placement: object, matchedComponent: object | null }} row Placement row.
640
+ * @returns {boolean}
641
+ */
642
+ static #canPromoteNearestOwner(row) {
643
+ if (
644
+ PcbScene3dStaticBodyOwnerPromotion.#canPromote(row) ||
645
+ PcbScene3dStaticBodyOwnerPromotion.#isCompactRow(row)
646
+ ) {
647
+ return true
648
+ }
649
+
650
+ return (
651
+ PcbScene3dStaticBodyOwnerPromotion.#isGenericSubpartDesignator(
652
+ row.placement
653
+ ) &&
654
+ PcbScene3dStaticBodyOwnerPromotion.#maxSpan(
655
+ row.placement?.geometry
656
+ ) <=
657
+ PcbScene3dStaticBodyOwnerPromotion
658
+ .#GENERIC_SUBPART_OWNER_MAX_SPAN_MIL
659
+ )
660
+ }
661
+
662
+ /**
663
+ * Checks whether one row is compact enough for cluster-center ownership.
664
+ * @param {{ placement: object }} row Placement row.
665
+ * @returns {boolean}
666
+ */
667
+ static #isCompactRow(row) {
668
+ return (
669
+ PcbScene3dStaticBodyOwnerPromotion.#maxSpan(
670
+ row.placement?.geometry
671
+ ) <= 40
672
+ )
673
+ }
674
+
675
+ /**
676
+ * Checks whether a placement uses only the generic static-body label.
677
+ * @param {{ designator?: string } | null} placement Static placement.
678
+ * @returns {boolean}
679
+ */
680
+ static #isGenericDesignator(placement) {
681
+ const designator = String(placement?.designator || '').trim()
682
+ return !designator || designator === '3D body'
683
+ }
684
+
685
+ /**
686
+ * Checks whether a placement identity names a generic package sub-part.
687
+ * @param {{ designator?: string, sourceIdentityKey?: string } | null} placement Static placement.
688
+ * @returns {boolean}
689
+ */
690
+ static #isGenericSubpartDesignator(placement) {
691
+ return PcbScene3dStaticBodyOwnerPromotion.#identityTokens(
692
+ placement
693
+ ).some((token) =>
694
+ PcbScene3dStaticBodyOwnerPromotion.#GENERIC_SUBPART_TOKENS.has(
695
+ token
696
+ )
697
+ )
698
+ }
699
+
700
+ /**
701
+ * Collects normalized identity tokens from one placement.
702
+ * @param {{ designator?: string, sourceIdentityKey?: string } | null} placement Static placement.
703
+ * @returns {string[]}
704
+ */
705
+ static #identityTokens(placement) {
706
+ return [placement?.designator, placement?.sourceIdentityKey]
707
+ .join(' ')
708
+ .replace(/([a-z])([A-Z])/gu, '$1 $2')
709
+ .toLowerCase()
710
+ .split(/[^a-z0-9]+/g)
711
+ .flatMap((fragment) => fragment.match(/[a-z]+|\d+/g) || [])
712
+ .filter(Boolean)
713
+ }
714
+
715
+ /**
716
+ * Applies a mount side while preserving the placement's XY anchor.
717
+ * @param {object} placement Static placement.
718
+ * @param {string | undefined} mountSide Owner mount side.
719
+ * @returns {object}
720
+ */
721
+ static #withMountSide(placement, mountSide) {
722
+ const currentSide =
723
+ PcbScene3dStaticBodyOwnerPromotion.#normalizeMountSide(
724
+ placement?.mountSide
725
+ )
726
+ const side = PcbScene3dStaticBodyOwnerPromotion.#normalizeMountSide(
727
+ mountSide || currentSide
728
+ )
729
+ const z = Math.abs(Number(placement?.positionMil?.z || 0))
730
+
731
+ return {
732
+ ...placement,
733
+ mountSide: side,
734
+ positionMil: {
735
+ ...(placement?.positionMil || {}),
736
+ z: side === 'bottom' ? -z : z
737
+ },
738
+ geometry:
739
+ currentSide === side
740
+ ? placement?.geometry
741
+ : PcbScene3dStaticBodyOwnerPromotion.#mirrorSourceCoordinateGeometry(
742
+ placement
743
+ )
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Normalizes a mount side token.
749
+ * @param {string | undefined} mountSide Candidate mount side.
750
+ * @returns {'top' | 'bottom'}
751
+ */
752
+ static #normalizeMountSide(mountSide) {
753
+ return String(mountSide || 'top')
754
+ .trim()
755
+ .toLowerCase() === 'bottom'
756
+ ? 'bottom'
757
+ : 'top'
758
+ }
759
+
760
+ /**
761
+ * Mirrors source-coordinate geometry into the opposite mount-local frame.
762
+ * @param {{ sourceCoordinateFrame?: boolean, geometry?: object }} placement Static placement.
763
+ * @returns {object | undefined}
764
+ */
765
+ static #mirrorSourceCoordinateGeometry(placement) {
766
+ const geometry = placement?.geometry
767
+ if (
768
+ !placement?.sourceCoordinateFrame ||
769
+ !Array.isArray(geometry?.verticesMil)
770
+ ) {
771
+ return geometry
772
+ }
773
+
774
+ return {
775
+ ...geometry,
776
+ verticesMil: geometry.verticesMil.map((vertex) => ({
777
+ x: PcbScene3dStaticBodyOwnerPromotion.#roundMil(
778
+ Number(vertex?.x || 0)
779
+ ),
780
+ y: PcbScene3dStaticBodyOwnerPromotion.#roundMil(
781
+ -Number(vertex?.y || 0)
782
+ )
783
+ }))
784
+ }
785
+ }
786
+
787
+ /**
788
+ * Applies a component owner to a placement.
789
+ * @param {object} placement Static placement.
790
+ * @param {{ designator?: string, layer?: string }} owner Owner component.
791
+ * @returns {object}
792
+ */
793
+ static #withOwner(placement, owner) {
794
+ return {
795
+ ...PcbScene3dStaticBodyOwnerPromotion.#withMountSide(
796
+ placement,
797
+ PcbScene3dStaticBodyOwnerPromotion.#componentMountSide(owner)
798
+ ),
799
+ designator:
800
+ String(owner?.designator || '').trim() || placement?.designator
801
+ }
802
+ }
803
+
804
+ /**
805
+ * Builds horizontal bounds for one static placement.
806
+ * @param {{ positionMil?: { x?: number, y?: number }, geometry?: object }} placement Static placement.
807
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
808
+ */
809
+ static #bounds(placement) {
810
+ const centerX = Number(placement?.positionMil?.x || 0)
811
+ const centerY = Number(placement?.positionMil?.y || 0)
812
+ const vertices = Array.isArray(placement?.geometry?.verticesMil)
813
+ ? placement.geometry.verticesMil
814
+ : []
815
+ if (!vertices.length) {
816
+ return {
817
+ minX: centerX,
818
+ minY: centerY,
819
+ maxX: centerX,
820
+ maxY: centerY
821
+ }
822
+ }
823
+
824
+ const xs = vertices.map((vertex) => Number(vertex?.x || 0))
825
+ const ys = vertices.map((vertex) => Number(vertex?.y || 0))
826
+
827
+ return {
828
+ minX: centerX + Math.min(...xs),
829
+ minY: centerY + Math.min(...ys),
830
+ maxX: centerX + Math.max(...xs),
831
+ maxY: centerY + Math.max(...ys)
832
+ }
833
+ }
834
+
835
+ /**
836
+ * Resolves the largest horizontal geometry span.
837
+ * @param {object | undefined} geometry Static geometry.
838
+ * @returns {number}
839
+ */
840
+ static #maxSpan(geometry) {
841
+ const vertices = Array.isArray(geometry?.verticesMil)
842
+ ? geometry.verticesMil
843
+ : []
844
+ if (!vertices.length) {
845
+ return 0
846
+ }
847
+
848
+ const xs = vertices.map((vertex) => Number(vertex?.x || 0))
849
+ const ys = vertices.map((vertex) => Number(vertex?.y || 0))
850
+
851
+ return Math.max(
852
+ Math.max(...xs) - Math.min(...xs),
853
+ Math.max(...ys) - Math.min(...ys)
854
+ )
855
+ }
856
+
857
+ /**
858
+ * Checks whether two horizontal bounds overlap or nearly touch.
859
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} left Bounds.
860
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} right Bounds.
861
+ * @param {number} toleranceMil Tolerance in mils.
862
+ * @returns {boolean}
863
+ */
864
+ static #touches(left, right, toleranceMil) {
865
+ const tolerance = Math.max(Number(toleranceMil || 0), 0)
866
+
867
+ return (
868
+ left.minX <= right.maxX + tolerance &&
869
+ left.maxX >= right.minX - tolerance &&
870
+ left.minY <= right.maxY + tolerance &&
871
+ left.maxY >= right.minY - tolerance
872
+ )
873
+ }
874
+
875
+ /**
876
+ * Rounds one mil value for stable promoted geometry output.
877
+ * @param {number} value Candidate value.
878
+ * @returns {number}
879
+ */
880
+ static #roundMil(value) {
881
+ const rounded = Math.round(Number(value) * 10000) / 10000
882
+ return Object.is(rounded, -0) ? 0 : rounded
883
+ }
884
+
885
+ /**
886
+ * Merges two bounds records.
887
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} left Bounds.
888
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} right Bounds.
889
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
890
+ */
891
+ static #merge(left, right) {
892
+ return {
893
+ minX: Math.min(left.minX, right.minX),
894
+ minY: Math.min(left.minY, right.minY),
895
+ maxX: Math.max(left.maxX, right.maxX),
896
+ maxY: Math.max(left.maxY, right.maxY)
897
+ }
898
+ }
899
+ }