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
@@ -3,6 +3,8 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { PcbScene3dPlacementSideResolver } from './PcbScene3dPlacementSideResolver.mjs'
6
+ import { PcbScene3dStaticBodyRecovery } from './PcbScene3dStaticBodyRecovery.mjs'
7
+ import { PcbScene3dStaticBodySelectionKeyBuilder } from './PcbScene3dStaticBodySelectionKeyBuilder.mjs'
6
8
 
7
9
  /**
8
10
  * Builds scene placements for static shape-based 3D bodies.
@@ -11,26 +13,50 @@ export class PcbScene3dStaticBodyPlacementBuilder {
11
13
  static #UNMATCHED_BODY_OVERHANG_RATIO = 0.25
12
14
  static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
13
15
  static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
16
+ static #OWNER_AFFINITY_DISTANCE_MIL = 600
17
+ static #OWNER_BOUNDS_TOLERANCE_MIL = 20
18
+ static #OWNER_EXACT_DISTANCE_MIL = 5
19
+ static #OWNER_EXACT_MAX_SPAN_MIL = 500
20
+ static #SOURCE_COORDINATE_MIRROR_TOLERANCE_MIL = 5
21
+ static #SOURCE_COORDINATE_MIRROR_MIN_OFFSET_MIL = 20
22
+ static #GENERIC_MECHANICAL_IDENTITY_TOKENS = new Set([
23
+ 'can',
24
+ 'cover',
25
+ 'enclosure',
26
+ 'frame',
27
+ 'hardware',
28
+ 'leg',
29
+ 'mechanical',
30
+ 'plate',
31
+ 'shield'
32
+ ])
14
33
 
15
34
  /**
16
35
  * Builds static shape-body scene placements.
17
- * @param {{ identifier?: string, name?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, standoffHeightMil?: number | null, overallHeightMil?: number | null, staticGeometry?: object }[]} componentBodies Component bodies.
36
+ * @param {{ componentIndex?: number, identifier?: string, name?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, standoffHeightMil?: number | null, overallHeightMil?: number | null, bodyOpacity?: number | string, staticGeometry?: object }[]} componentBodies Component bodies.
18
37
  * @param {({ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null)[]} bodyMatches Matched components.
19
38
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
39
+ * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads Pads.
20
40
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
21
41
  * @param {number} thicknessMil Board thickness.
22
- * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object }[]}
42
+ * @returns {{ designator: string, selectionKey: string, sourceIdentityKey: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object }[]}
23
43
  */
24
44
  static build(
25
45
  componentBodies,
26
46
  bodyMatches,
27
47
  components,
48
+ pads,
28
49
  board,
29
50
  thicknessMil
30
51
  ) {
31
- return (Array.isArray(componentBodies) ? componentBodies : [])
52
+ const recoveredBodies = PcbScene3dStaticBodyRecovery.recover(
53
+ componentBodies,
54
+ bodyMatches
55
+ )
56
+
57
+ const placementRows = recoveredBodies
32
58
  .map((componentBody, index) =>
33
- PcbScene3dStaticBodyPlacementBuilder.#buildPlacement(
59
+ PcbScene3dStaticBodyPlacementBuilder.#buildPlacementRow(
34
60
  componentBody,
35
61
  bodyMatches?.[index] || null,
36
62
  components,
@@ -39,16 +65,59 @@ export class PcbScene3dStaticBodyPlacementBuilder {
39
65
  )
40
66
  )
41
67
  .filter(Boolean)
68
+
69
+ return PcbScene3dStaticBodySelectionKeyBuilder.assign(placementRows)
70
+ }
71
+
72
+ /**
73
+ * Checks whether one static geometry is already complete.
74
+ * @param {object | undefined} geometry Static geometry.
75
+ * @returns {boolean}
76
+ */
77
+ static #isCompleteGeometry(geometry) {
78
+ return Boolean(geometry && geometry.status === 'complete')
79
+ }
80
+
81
+ /**
82
+ * Builds one static body placement row with its matched owner context.
83
+ * @param {{ componentIndex?: number, embedded?: boolean, identifier?: string, name?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, standoffHeightMil?: number | null, overallHeightMil?: number | null, bodyOpacity?: number | string, staticGeometry?: object }} componentBody Component body.
84
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent Matched component.
85
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
86
+ * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
87
+ * @param {number} thicknessMil Board thickness.
88
+ * @returns {{ placement: { designator: string, sourceIdentityKey: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object }, matchedComponent: object | null } | null}
89
+ */
90
+ static #buildPlacementRow(
91
+ componentBody,
92
+ matchedComponent,
93
+ components,
94
+ board,
95
+ thicknessMil
96
+ ) {
97
+ const placement = PcbScene3dStaticBodyPlacementBuilder.#buildPlacement(
98
+ componentBody,
99
+ matchedComponent,
100
+ components,
101
+ board,
102
+ thicknessMil
103
+ )
104
+
105
+ return placement
106
+ ? {
107
+ placement,
108
+ matchedComponent
109
+ }
110
+ : null
42
111
  }
43
112
 
44
113
  /**
45
114
  * Builds one static shape-body scene placement.
46
- * @param {{ identifier?: string, name?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, standoffHeightMil?: number | null, overallHeightMil?: number | null, staticGeometry?: object }} componentBody Component body.
115
+ * @param {{ componentIndex?: number, embedded?: boolean, identifier?: string, name?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, standoffHeightMil?: number | null, overallHeightMil?: number | null, bodyOpacity?: number | string, staticGeometry?: object }} componentBody Component body.
47
116
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent Matched component.
48
117
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
49
118
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
50
119
  * @param {number} thicknessMil Board thickness.
51
- * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object } | null}
120
+ * @returns {{ designator: string, sourceIdentityKey: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object } | null}
52
121
  */
53
122
  static #buildPlacement(
54
123
  componentBody,
@@ -57,9 +126,32 @@ export class PcbScene3dStaticBodyPlacementBuilder {
57
126
  board,
58
127
  thicknessMil
59
128
  ) {
60
- const geometry = componentBody?.staticGeometry
129
+ const staticGeometry =
130
+ PcbScene3dStaticBodyPlacementBuilder.#normalizeStaticGeometry(
131
+ componentBody,
132
+ componentBody?.staticGeometry,
133
+ matchedComponent
134
+ )
135
+ const geometry = staticGeometry.geometry
136
+
137
+ if (
138
+ !PcbScene3dStaticBodyPlacementBuilder.#isCompleteGeometry(geometry)
139
+ ) {
140
+ return null
141
+ }
142
+ const sourcePosition =
143
+ staticGeometry.placementCenterMil ||
144
+ PcbScene3dStaticBodyPlacementBuilder.#sourcePosition(componentBody)
61
145
 
62
- if (!geometry || geometry.status !== 'complete') {
146
+ if (
147
+ !matchedComponent &&
148
+ PcbScene3dStaticBodyPlacementBuilder.#shouldSuppressStaticBody(
149
+ componentBody,
150
+ geometry,
151
+ components,
152
+ sourcePosition
153
+ )
154
+ ) {
63
155
  return null
64
156
  }
65
157
 
@@ -73,14 +165,13 @@ export class PcbScene3dStaticBodyPlacementBuilder {
73
165
  return null
74
166
  }
75
167
 
76
- const mountSide = PcbScene3dPlacementSideResolver.resolvePlacementSide(
77
- componentBody,
78
- matchedComponent,
79
- components,
80
- board
81
- )
82
- const sourcePosition =
83
- PcbScene3dStaticBodyPlacementBuilder.#sourcePosition(componentBody)
168
+ const mountSide =
169
+ PcbScene3dPlacementSideResolver.resolveStaticBodyPlacementSide(
170
+ componentBody,
171
+ matchedComponent,
172
+ components,
173
+ board
174
+ )
84
175
  const heightMil =
85
176
  PcbScene3dStaticBodyPlacementBuilder.#geometryHeight(geometry)
86
177
  const standoffMil = Math.abs(
@@ -99,6 +190,10 @@ export class PcbScene3dStaticBodyPlacementBuilder {
99
190
  String(
100
191
  componentBody.identifier || componentBody.name || '3D body'
101
192
  ),
193
+ sourceIdentityKey:
194
+ PcbScene3dStaticBodyPlacementBuilder.#sourceIdentityKey(
195
+ componentBody
196
+ ),
102
197
  mountSide,
103
198
  rotationDeg: PcbScene3dStaticBodyPlacementBuilder.#normalizeAngle(
104
199
  Number(componentBody.rotationDeg || 0) +
@@ -119,10 +214,537 @@ export class PcbScene3dStaticBodyPlacementBuilder {
119
214
  x: Number(componentBody.positionMil?.x || 0),
120
215
  y: Number(componentBody.positionMil?.y || 0)
121
216
  },
217
+ ...PcbScene3dStaticBodyPlacementBuilder.#displayMetadata(
218
+ componentBody
219
+ ),
122
220
  geometry
123
221
  }
124
222
  }
125
223
 
224
+ /**
225
+ * Converts source-space polygon vertices into render-local geometry.
226
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
227
+ * @param {object | undefined} geometry Static geometry.
228
+ * @param {{ x?: number, y?: number } | null} matchedComponent Matched owner component.
229
+ * @returns {{ geometry: object | undefined, placementCenterMil?: { x: number, y: number } }}
230
+ */
231
+ static #normalizeStaticGeometry(componentBody, geometry, matchedComponent) {
232
+ if (
233
+ String(geometry?.kind || '').toLowerCase() !== 'extruded-polygon' ||
234
+ !Array.isArray(geometry?.verticesMil) ||
235
+ geometry.verticesMil.length < 3 ||
236
+ !PcbScene3dStaticBodyPlacementBuilder.#usesSourceCoordinateFrame(
237
+ componentBody,
238
+ geometry.verticesMil
239
+ )
240
+ ) {
241
+ return { geometry }
242
+ }
243
+
244
+ const center =
245
+ PcbScene3dStaticBodyPlacementBuilder.#polygonBoundsCenter(
246
+ geometry.verticesMil
247
+ )
248
+ const mirror =
249
+ PcbScene3dStaticBodyPlacementBuilder.#sourceCoordinateMirror(
250
+ componentBody,
251
+ center,
252
+ matchedComponent
253
+ )
254
+ const placementCenter = mirror?.placementCenterMil || center
255
+
256
+ return {
257
+ placementCenterMil: placementCenter,
258
+ geometry: {
259
+ ...geometry,
260
+ verticesMil: geometry.verticesMil.map((vertex) => {
261
+ const normalizedVertex =
262
+ PcbScene3dStaticBodyPlacementBuilder.#normalizeVertex(
263
+ vertex,
264
+ mirror
265
+ )
266
+
267
+ return {
268
+ x: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
269
+ normalizedVertex.x - placementCenter.x
270
+ ),
271
+ y: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
272
+ normalizedVertex.y - placementCenter.y
273
+ )
274
+ }
275
+ })
276
+ }
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Resolves owner-axis mirror transforms for source-coordinate polygons
282
+ * whose body anchor is opposite their raw polygon bounds.
283
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
284
+ * @param {{ x: number, y: number }} boundsCenter Source-coordinate polygon bounds center.
285
+ * @param {{ x?: number, y?: number } | null} matchedComponent Matched owner component.
286
+ * @returns {{ mirrorAxisX?: number, mirrorAxisY?: number, placementCenterMil: { x: number, y: number } } | null}
287
+ */
288
+ static #sourceCoordinateMirror(
289
+ componentBody,
290
+ boundsCenter,
291
+ matchedComponent
292
+ ) {
293
+ const source =
294
+ PcbScene3dStaticBodyPlacementBuilder.#sourcePosition(componentBody)
295
+ const xDecision =
296
+ PcbScene3dStaticBodyPlacementBuilder.#sourceCoordinateMirrorAxis(
297
+ boundsCenter.x,
298
+ source.x,
299
+ Number(matchedComponent?.x)
300
+ )
301
+ const yDecision =
302
+ PcbScene3dStaticBodyPlacementBuilder.#sourceCoordinateMirrorAxis(
303
+ boundsCenter.y,
304
+ source.y,
305
+ Number(matchedComponent?.y)
306
+ )
307
+
308
+ if (
309
+ !xDecision.valid ||
310
+ !yDecision.valid ||
311
+ (!xDecision.mirrored && !yDecision.mirrored)
312
+ ) {
313
+ return null
314
+ }
315
+
316
+ return {
317
+ mirrorAxisX: xDecision.mirrored
318
+ ? Number(matchedComponent?.x)
319
+ : undefined,
320
+ mirrorAxisY: yDecision.mirrored
321
+ ? Number(matchedComponent?.y)
322
+ : undefined,
323
+ placementCenterMil: {
324
+ x: source.x,
325
+ y: source.y
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Checks whether one source-coordinate axis is aligned or mirrored.
332
+ * @param {number} center Raw polygon center coordinate.
333
+ * @param {number} source Body source coordinate.
334
+ * @param {number} ownerAxis Owner coordinate.
335
+ * @returns {{ valid: boolean, mirrored: boolean }}
336
+ */
337
+ static #sourceCoordinateMirrorAxis(center, source, ownerAxis) {
338
+ const tolerance =
339
+ PcbScene3dStaticBodyPlacementBuilder
340
+ .#SOURCE_COORDINATE_MIRROR_TOLERANCE_MIL
341
+ const offset = Math.abs(Number(center || 0) - Number(source || 0))
342
+ if (offset <= tolerance) {
343
+ return { valid: true, mirrored: false }
344
+ }
345
+
346
+ const mirroredCenter = 2 * Number(ownerAxis) - Number(center || 0)
347
+ const mirrorError = Math.abs(mirroredCenter - Number(source || 0))
348
+
349
+ return {
350
+ valid:
351
+ Number.isFinite(ownerAxis) &&
352
+ offset >
353
+ PcbScene3dStaticBodyPlacementBuilder
354
+ .#SOURCE_COORDINATE_MIRROR_MIN_OFFSET_MIL &&
355
+ mirrorError <= tolerance,
356
+ mirrored: true
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Converts one source-space vertex into render source-space coordinates.
362
+ * @param {{ x?: number, y?: number }} vertex Source vertex.
363
+ * @param {{ mirrorAxisX?: number, mirrorAxisY?: number } | null} mirror Optional mirror transform.
364
+ * @returns {{ x: number, y: number }}
365
+ */
366
+ static #normalizeVertex(vertex, mirror) {
367
+ const sourceX = Number(vertex?.x || 0)
368
+ const sourceY = Number(vertex?.y || 0)
369
+
370
+ return {
371
+ x: Number.isFinite(mirror?.mirrorAxisX)
372
+ ? 2 * mirror.mirrorAxisX - sourceX
373
+ : sourceX,
374
+ y: Number.isFinite(mirror?.mirrorAxisY)
375
+ ? 2 * mirror.mirrorAxisY - sourceY
376
+ : sourceY
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Checks whether polygon vertices use board/source coordinates instead of
382
+ * small body-local coordinates.
383
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
384
+ * @param {{ x?: number, y?: number }[]} vertices Vertices.
385
+ * @returns {boolean}
386
+ */
387
+ static #usesSourceCoordinateFrame(componentBody, vertices) {
388
+ const center =
389
+ PcbScene3dStaticBodyPlacementBuilder.#polygonBoundsCenter(vertices)
390
+ const source =
391
+ PcbScene3dStaticBodyPlacementBuilder.#sourcePosition(componentBody)
392
+
393
+ return (
394
+ Math.max(
395
+ Math.abs(center.x),
396
+ Math.abs(center.y),
397
+ Math.abs(source.x),
398
+ Math.abs(source.y)
399
+ ) > 1000
400
+ )
401
+ }
402
+
403
+ /**
404
+ * Resolves the axis-aligned polygon bounds center.
405
+ * @param {{ x?: number, y?: number }[]} vertices Vertices.
406
+ * @returns {{ x: number, y: number }}
407
+ */
408
+ static #polygonBoundsCenter(vertices) {
409
+ const points = (Array.isArray(vertices) ? vertices : []).map(
410
+ (vertex) => ({
411
+ x: Number(vertex?.x || 0),
412
+ y: Number(vertex?.y || 0)
413
+ })
414
+ )
415
+ const xs = points.map((point) => point.x)
416
+ const ys = points.map((point) => point.y)
417
+
418
+ return {
419
+ x: (Math.min(...xs) + Math.max(...xs)) / 2,
420
+ y: (Math.min(...ys) + Math.max(...ys)) / 2
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Resolves optional display metadata for static body rendering.
426
+ * @param {{ bodyColor?: object, bodyOpacity?: number | string }} componentBody Component body.
427
+ * @returns {{ bodyColor?: object, bodyOpacity?: number }}
428
+ */
429
+ static #displayMetadata(componentBody) {
430
+ const metadata = {}
431
+ if (
432
+ componentBody?.bodyColor &&
433
+ typeof componentBody.bodyColor === 'object'
434
+ ) {
435
+ metadata.bodyColor = componentBody.bodyColor
436
+ }
437
+
438
+ const opacity = PcbScene3dStaticBodyRecovery.renderableOpacity(
439
+ componentBody?.bodyOpacity
440
+ )
441
+ if (opacity !== undefined) {
442
+ metadata.bodyOpacity = opacity
443
+ }
444
+
445
+ return metadata
446
+ }
447
+
448
+ /**
449
+ * Resolves the stable source identity shared by sibling static bodies.
450
+ * @param {{ identifier?: string, name?: string, modelId?: string }} componentBody Component body.
451
+ * @returns {string}
452
+ */
453
+ static #sourceIdentityKey(componentBody) {
454
+ return String(
455
+ componentBody?.identifier ||
456
+ componentBody?.name ||
457
+ componentBody?.modelId ||
458
+ ''
459
+ ).trim()
460
+ }
461
+
462
+ /**
463
+ * Checks whether an unowned static body is likely a translucent mechanical
464
+ * overlay instead of a component package.
465
+ * @param {{ componentIndex?: number, embedded?: boolean, bodyOpacity?: number | string, identifier?: string, name?: string, positionMil?: { x?: number, y?: number } }} componentBody Component body.
466
+ * @param {object} geometry Static geometry.
467
+ * @param {{ componentIndex?: number, x?: number, y?: number, pattern?: string, source?: string, modelPath?: string, description?: string, parameters?: object, provenance?: object }[]} components Components.
468
+ * @param {{ x: number, y: number }} sourcePosition Source-space placement center.
469
+ * @returns {boolean}
470
+ */
471
+ static #shouldSuppressStaticBody(
472
+ componentBody,
473
+ geometry,
474
+ components,
475
+ sourcePosition
476
+ ) {
477
+ return (
478
+ PcbScene3dStaticBodyPlacementBuilder.#isTranslucentBody(
479
+ componentBody
480
+ ) &&
481
+ !PcbScene3dStaticBodyPlacementBuilder.#hasLikelyComponentOwner(
482
+ componentBody,
483
+ geometry,
484
+ components,
485
+ sourcePosition
486
+ )
487
+ )
488
+ }
489
+
490
+ /**
491
+ * Checks whether a body was authored with visible partial transparency.
492
+ * @param {{ bodyOpacity?: number | string }} componentBody Component body.
493
+ * @returns {boolean}
494
+ */
495
+ static #isTranslucentBody(componentBody) {
496
+ return (
497
+ PcbScene3dStaticBodyRecovery.renderableOpacity(
498
+ componentBody?.bodyOpacity
499
+ ) !== undefined
500
+ )
501
+ }
502
+
503
+ /**
504
+ * Checks whether an otherwise unmatched body still has a plausible owning
505
+ * component.
506
+ * @param {{ componentIndex?: number, embedded?: boolean, identifier?: string, name?: string, positionMil?: { x?: number, y?: number } }} componentBody Component body.
507
+ * @param {object} geometry Static geometry.
508
+ * @param {{ componentIndex?: number, x?: number, y?: number, pattern?: string, source?: string, modelPath?: string, description?: string, parameters?: object, provenance?: object }[]} components Components.
509
+ * @param {{ x: number, y: number }} sourcePosition Source-space placement center.
510
+ * @returns {boolean}
511
+ */
512
+ static #hasLikelyComponentOwner(
513
+ componentBody,
514
+ geometry,
515
+ components,
516
+ sourcePosition
517
+ ) {
518
+ if (componentBody?.embedded) {
519
+ return true
520
+ }
521
+
522
+ const componentIndex = Number(componentBody?.componentIndex)
523
+ if (
524
+ Number.isInteger(componentIndex) &&
525
+ (Array.isArray(components) ? components : []).some(
526
+ (component) =>
527
+ Number(component?.componentIndex) === componentIndex
528
+ )
529
+ ) {
530
+ return true
531
+ }
532
+
533
+ const maxSpanMil =
534
+ PcbScene3dStaticBodyPlacementBuilder.#geometryMaxSpan(geometry)
535
+ const genericMechanical =
536
+ PcbScene3dStaticBodyPlacementBuilder.#hasGenericMechanicalIdentity(
537
+ componentBody
538
+ )
539
+ const sourceBounds =
540
+ PcbScene3dStaticBodyPlacementBuilder.#geometrySourceBounds(
541
+ geometry,
542
+ sourcePosition
543
+ )
544
+
545
+ return (Array.isArray(components) ? components : []).some(
546
+ (component) => {
547
+ const distance =
548
+ PcbScene3dStaticBodyPlacementBuilder.#distanceBetweenBodyAndComponent(
549
+ componentBody,
550
+ component
551
+ )
552
+ const affinityScore =
553
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
554
+ componentBody,
555
+ component
556
+ )
557
+
558
+ return (
559
+ (genericMechanical &&
560
+ PcbScene3dStaticBodyPlacementBuilder.#isShieldOwner(
561
+ component
562
+ ) &&
563
+ (distance <=
564
+ PcbScene3dStaticBodyPlacementBuilder
565
+ .#OWNER_AFFINITY_DISTANCE_MIL ||
566
+ PcbScene3dStaticBodyPlacementBuilder.#boundsContainPoint(
567
+ sourceBounds,
568
+ component,
569
+ PcbScene3dStaticBodyPlacementBuilder
570
+ .#OWNER_BOUNDS_TOLERANCE_MIL
571
+ ))) ||
572
+ (!genericMechanical &&
573
+ affinityScore > 0 &&
574
+ distance <=
575
+ PcbScene3dStaticBodyPlacementBuilder
576
+ .#OWNER_AFFINITY_DISTANCE_MIL) ||
577
+ (!genericMechanical &&
578
+ distance <=
579
+ PcbScene3dStaticBodyPlacementBuilder
580
+ .#OWNER_EXACT_DISTANCE_MIL &&
581
+ maxSpanMil <=
582
+ PcbScene3dStaticBodyPlacementBuilder
583
+ .#OWNER_EXACT_MAX_SPAN_MIL)
584
+ )
585
+ }
586
+ )
587
+ }
588
+
589
+ /**
590
+ * Resolves source-space bounds for one static polygon geometry.
591
+ * @param {object} geometry Static geometry.
592
+ * @param {{ x?: number, y?: number }} sourcePosition Source-space placement center.
593
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
594
+ */
595
+ static #geometrySourceBounds(geometry, sourcePosition) {
596
+ if (
597
+ !Array.isArray(geometry?.verticesMil) ||
598
+ !geometry.verticesMil.length
599
+ ) {
600
+ return null
601
+ }
602
+
603
+ const points = geometry.verticesMil
604
+ .map((vertex) => ({
605
+ x: Number(vertex?.x || 0) + Number(sourcePosition?.x || 0),
606
+ y: Number(vertex?.y || 0) + Number(sourcePosition?.y || 0)
607
+ }))
608
+ .filter(
609
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
610
+ )
611
+ if (!points.length) {
612
+ return null
613
+ }
614
+
615
+ const xs = points.map((point) => point.x)
616
+ const ys = points.map((point) => point.y)
617
+
618
+ return {
619
+ minX: Math.min(...xs),
620
+ minY: Math.min(...ys),
621
+ maxX: Math.max(...xs),
622
+ maxY: Math.max(...ys)
623
+ }
624
+ }
625
+
626
+ /**
627
+ * Checks whether bounds contain a component point with tolerance.
628
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number } | null} bounds Bounds.
629
+ * @param {{ x?: number, y?: number }} point Candidate point.
630
+ * @param {number} toleranceMil Bounds tolerance.
631
+ * @returns {boolean}
632
+ */
633
+ static #boundsContainPoint(bounds, point, toleranceMil) {
634
+ if (!bounds) {
635
+ return false
636
+ }
637
+
638
+ const x = Number(point?.x || 0)
639
+ const y = Number(point?.y || 0)
640
+ const tolerance = Math.max(Number(toleranceMil || 0), 0)
641
+
642
+ return (
643
+ Number.isFinite(x) &&
644
+ Number.isFinite(y) &&
645
+ x >= bounds.minX - tolerance &&
646
+ x <= bounds.maxX + tolerance &&
647
+ y >= bounds.minY - tolerance &&
648
+ y <= bounds.maxY + tolerance
649
+ )
650
+ }
651
+
652
+ /**
653
+ * Checks whether a component identity describes a shield owner strongly
654
+ * enough to keep generic translucent shield sub-bodies.
655
+ * @param {{ designator?: string, pattern?: string, source?: string, description?: string, parameters?: object, provenance?: object }} component Component row.
656
+ * @returns {boolean}
657
+ */
658
+ static #isShieldOwner(component) {
659
+ const identityText = [
660
+ component?.pattern,
661
+ component?.source,
662
+ component?.description,
663
+ ...Object.values(component?.parameters || {}),
664
+ ...Object.values(component?.provenance || {})
665
+ ]
666
+ .join(' ')
667
+ .toLowerCase()
668
+
669
+ return /(?:^|[^a-z0-9])(?:emi|rfi|shield)(?:$|[^a-z0-9])/u.test(
670
+ identityText
671
+ )
672
+ }
673
+
674
+ /**
675
+ * Checks for generic mechanical labels that are too weak to prove package
676
+ * ownership for translucent static bodies.
677
+ * @param {{ identifier?: string, name?: string }} componentBody Component body.
678
+ * @returns {boolean}
679
+ */
680
+ static #hasGenericMechanicalIdentity(componentBody) {
681
+ return PcbScene3dStaticBodyPlacementBuilder.#identityTokens(
682
+ componentBody
683
+ ).some((token) =>
684
+ PcbScene3dStaticBodyPlacementBuilder.#GENERIC_MECHANICAL_IDENTITY_TOKENS.has(
685
+ token
686
+ )
687
+ )
688
+ }
689
+
690
+ /**
691
+ * Collects normalized identity tokens from one body row.
692
+ * @param {{ identifier?: string, name?: string }} componentBody Component body.
693
+ * @returns {string[]}
694
+ */
695
+ static #identityTokens(componentBody) {
696
+ return [componentBody?.identifier, componentBody?.name]
697
+ .join(' ')
698
+ .replace(/([a-z])([A-Z])/gu, '$1 $2')
699
+ .toLowerCase()
700
+ .split(/[^a-z0-9]+/g)
701
+ .flatMap((fragment) => fragment.match(/[a-z]+|\d+/g) || [])
702
+ .filter(Boolean)
703
+ }
704
+
705
+ /**
706
+ * Resolves the largest horizontal span of a static body.
707
+ * @param {object} geometry Static geometry.
708
+ * @returns {number}
709
+ */
710
+ static #geometryMaxSpan(geometry) {
711
+ if (
712
+ Array.isArray(geometry?.verticesMil) &&
713
+ geometry.verticesMil.length
714
+ ) {
715
+ const points = geometry.verticesMil.map((vertex) => ({
716
+ x: Number(vertex?.x || 0),
717
+ y: Number(vertex?.y || 0)
718
+ }))
719
+ const xs = points.map((point) => point.x)
720
+ const ys = points.map((point) => point.y)
721
+
722
+ return Math.max(
723
+ Math.max(...xs) - Math.min(...xs),
724
+ Math.max(...ys) - Math.min(...ys)
725
+ )
726
+ }
727
+
728
+ const radius = Number(geometry?.radiusMil)
729
+
730
+ return Number.isFinite(radius) && radius > 0 ? radius * 2 : 0
731
+ }
732
+
733
+ /**
734
+ * Returns the euclidean distance between body and component anchors.
735
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
736
+ * @param {{ x?: number, y?: number }} component Component.
737
+ * @returns {number}
738
+ */
739
+ static #distanceBetweenBodyAndComponent(componentBody, component) {
740
+ return Math.hypot(
741
+ Number(component?.x || 0) -
742
+ Number(componentBody?.positionMil?.x || 0),
743
+ Number(component?.y || 0) -
744
+ Number(componentBody?.positionMil?.y || 0)
745
+ )
746
+ }
747
+
126
748
  /**
127
749
  * Returns the native body anchor.
128
750
  * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.