altium-toolkit 1.1.35 → 1.1.37

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