altium-toolkit 1.1.32 → 1.1.33

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.
@@ -9,6 +9,7 @@ import { AltiumScene3dExternalPlacementAdapter } from './AltiumScene3dExternalPl
9
9
  import { AltiumScene3dBottomPadRotationAdapter } from './AltiumScene3dBottomPadRotationAdapter.mjs'
10
10
  import { AltiumScene3dComponentBodyAdapter } from './AltiumScene3dComponentBodyAdapter.mjs'
11
11
  import { AltiumScene3dAuthoredBodyAnchorAdapter } from './AltiumScene3dAuthoredBodyAnchorAdapter.mjs'
12
+ import { AltiumScene3dShapeStackOwnerAdapter } from './AltiumScene3dShapeStackOwnerAdapter.mjs'
12
13
  import { PcbFootprintPrimitiveSelector } from './PcbFootprintPrimitiveSelector.mjs'
13
14
  import { PcbScene3dPadLocalSpanResolver } from './PcbScene3dPadLocalSpanResolver.mjs'
14
15
  import { PcbScene3dPackages } from './PcbScene3dPackages.mjs'
@@ -31,7 +32,10 @@ export class PcbScene3dBuilder {
31
32
  static #UNMATCHED_BODY_OVERHANG_RATIO = 0.25
32
33
  static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
33
34
  static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
35
+ static #OVERSIZED_GENERIC_FALLBACK_MAX_MIL = 800
34
36
  static #TRUETYPE_TEXT_WIDTH_RATIO = 0.55
37
+ static #LOW_CONFIDENCE_GENERIC_FOOTPRINT_PATTERN =
38
+ /(?:^|[^a-z0-9])(?:edge|finger|fingers|contact|contacts|mech|mechanical|jumper|jump)(?:$|[^a-z0-9])/i
35
39
  static #AUTHORED_BODY_IDENTITY_PATTERN =
36
40
  /(?:^|[^a-z0-9])(?:antenna|coax|conn|connector|edge|flex|fpc|frame|hardware|header|jack|mechanical|module|mount|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
37
41
  static #COMPONENT_PACKAGE_BODY_PATTERN =
@@ -141,15 +145,17 @@ export class PcbScene3dBuilder {
141
145
  boardAssemblyModel:
142
146
  modelRegistry?.resolveBoardAssemblyModel?.(documentModel) ||
143
147
  null,
144
- components: components.map((component) =>
145
- PcbScene3dBuilder.#buildComponent(
146
- component,
147
- pads,
148
- board,
149
- thicknessMil,
150
- modelRegistry
148
+ components: components
149
+ .map((component) =>
150
+ PcbScene3dBuilder.#buildComponent(
151
+ component,
152
+ pads,
153
+ board,
154
+ thicknessMil,
155
+ modelRegistry
156
+ )
151
157
  )
152
- ),
158
+ .filter(Boolean),
153
159
  externalPlacements: componentBodies
154
160
  .map((componentBody, index) =>
155
161
  PcbScene3dBuilder.#buildExternalPlacement(
@@ -167,6 +173,7 @@ export class PcbScene3dBuilder {
167
173
  componentBodies,
168
174
  bodyMatches,
169
175
  components,
176
+ pads,
170
177
  board,
171
178
  thicknessMil
172
179
  ),
@@ -191,8 +198,11 @@ export class PcbScene3dBuilder {
191
198
  AltiumScene3dBottomPadRotationAdapter.apply(
192
199
  AltiumScene3dComponentBodyAdapter.apply(
193
200
  AltiumScene3dExternalPlacementAdapter.apply(
194
- PcbScene3dBoardOutlineRefiner.refine(
195
- sceneDescription,
201
+ AltiumScene3dShapeStackOwnerAdapter.apply(
202
+ PcbScene3dBoardOutlineRefiner.refine(
203
+ sceneDescription,
204
+ sceneDocumentModel
205
+ ),
196
206
  sceneDocumentModel
197
207
  ),
198
208
  sceneDocumentModel
@@ -210,7 +220,7 @@ export class PcbScene3dBuilder {
210
220
  * @param {{ centerX: number, centerY: number }} board
211
221
  * @param {number} thicknessMil
212
222
  * @param {{ resolveComponentModel: (component: any) => { name: string, relativePath: string, format: string } | null } | null} modelRegistry
213
- * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, boardPositionMil: { x: number, y: number, z: number }, pattern: string, source: string, description: string, parameters: Record<string, unknown>, body: { family: string, sizeMil: { width: number, depth: number, height: number } }, externalModel: { name: string, relativePath: string, format: string } | null }}
223
+ * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, boardPositionMil: { x: number, y: number, z: number }, pattern: string, source: string, description: string, parameters: Record<string, unknown>, body: { family: string, sizeMil: { width: number, depth: number, height: number } }, externalModel: { name: string, relativePath: string, format: string } | null } | null}
214
224
  */
215
225
  static #buildComponent(
216
226
  component,
@@ -222,6 +232,19 @@ export class PcbScene3dBuilder {
222
232
  const mountSide = PcbScene3dBuilder.#resolveMountSide(component)
223
233
  const padSpan = PcbScene3dBuilder.#resolvePadSpan(component, pads)
224
234
  const body = PcbScene3dPackages.resolve(component, padSpan)
235
+ const externalModel = modelRegistry
236
+ ? modelRegistry.resolveComponentModel(component)
237
+ : null
238
+ if (
239
+ PcbScene3dBuilder.#shouldSuppressProceduralComponent(
240
+ component,
241
+ body,
242
+ externalModel
243
+ )
244
+ ) {
245
+ return null
246
+ }
247
+
225
248
  const halfBoardThickness = thicknessMil / 2
226
249
  const halfBodyHeight = body.sizeMil.height / 2
227
250
  const z =
@@ -253,12 +276,56 @@ export class PcbScene3dBuilder {
253
276
  ? { ...component.parameters }
254
277
  : {},
255
278
  body,
256
- externalModel: modelRegistry
257
- ? modelRegistry.resolveComponentModel(component)
258
- : null
279
+ externalModel
259
280
  }
260
281
  }
261
282
 
283
+ /**
284
+ * Checks whether one generated fallback body is too uncertain to render.
285
+ * @param {{ pattern?: string, source?: string, description?: string }} component Source component.
286
+ * @param {{ family?: string, sizeMil?: { width?: number, depth?: number } }} body Procedural body.
287
+ * @param {object | null} externalModel Resolved external model.
288
+ * @returns {boolean}
289
+ */
290
+ static #shouldSuppressProceduralComponent(component, body, externalModel) {
291
+ return (
292
+ !externalModel &&
293
+ body?.family === 'generic' &&
294
+ PcbScene3dBuilder.#isOversizedGenericFallback(body) &&
295
+ PcbScene3dBuilder.#isLowConfidenceGenericFootprint(component)
296
+ )
297
+ }
298
+
299
+ /**
300
+ * Checks whether one generic fallback body spans too much of the board.
301
+ * @param {{ sizeMil?: { width?: number, depth?: number } }} body Procedural body.
302
+ * @returns {boolean}
303
+ */
304
+ static #isOversizedGenericFallback(body) {
305
+ return (
306
+ Math.max(
307
+ Number(body?.sizeMil?.width || 0),
308
+ Number(body?.sizeMil?.depth || 0)
309
+ ) > PcbScene3dBuilder.#OVERSIZED_GENERIC_FALLBACK_MAX_MIL
310
+ )
311
+ }
312
+
313
+ /**
314
+ * Checks for footprint identities that describe board features or
315
+ * mechanical placeholders more often than physical package bodies.
316
+ * @param {{ pattern?: string, source?: string, description?: string }} component Source component.
317
+ * @returns {boolean}
318
+ */
319
+ static #isLowConfidenceGenericFootprint(component) {
320
+ return PcbScene3dBuilder.#LOW_CONFIDENCE_GENERIC_FOOTPRINT_PATTERN.test(
321
+ [
322
+ component?.pattern,
323
+ component?.source,
324
+ component?.description
325
+ ].join(' ')
326
+ )
327
+ }
328
+
262
329
  /**
263
330
  * Resolves one component-body model through the active registry.
264
331
  * @param {{ modelId?: string, checksum?: number | null, name?: string }} componentBody Component body metadata.
@@ -352,7 +419,8 @@ export class PcbScene3dBuilder {
352
419
  modelTransform: {
353
420
  rotationDeg: modelRotation,
354
421
  dzMil: PcbScene3dBuilder.#resolveComponentBodyVerticalOffset(
355
- componentBody
422
+ componentBody,
423
+ matchedComponent
356
424
  )
357
425
  },
358
426
  projection: PcbScene3dBuilder.#resolveProjectionDiagnostics(
@@ -369,16 +437,65 @@ export class PcbScene3dBuilder {
369
437
  * Resolves the vertical offset that should remain after the viewer seats
370
438
  * raw model bounds on the board face.
371
439
  * @param {{ dzMil?: number, standoffHeightMil?: number | null }} componentBody Component-body placement metadata.
440
+ * @param {object | null} matchedComponent Matched owner component.
372
441
  * @returns {number}
373
442
  */
374
- static #resolveComponentBodyVerticalOffset(componentBody) {
443
+ static #resolveComponentBodyVerticalOffset(
444
+ componentBody,
445
+ matchedComponent = null
446
+ ) {
375
447
  const standoffHeightMil = Number(componentBody?.standoffHeightMil)
376
448
  if (Number.isFinite(standoffHeightMil)) {
377
- return standoffHeightMil < 0 ? standoffHeightMil : 0
449
+ return standoffHeightMil < 0 ||
450
+ PcbScene3dBuilder.#shouldPreservePositiveBodyStandoff(
451
+ componentBody,
452
+ matchedComponent
453
+ )
454
+ ? standoffHeightMil
455
+ : 0
378
456
  }
379
457
 
380
458
  const dzMil = Number(componentBody?.dzMil)
381
- return Number.isFinite(dzMil) && dzMil < 0 ? dzMil : 0
459
+ return Number.isFinite(dzMil) &&
460
+ (dzMil < 0 ||
461
+ PcbScene3dBuilder.#shouldPreservePositiveBodyStandoff(
462
+ componentBody,
463
+ matchedComponent
464
+ ))
465
+ ? dzMil
466
+ : 0
467
+ }
468
+
469
+ /**
470
+ * Checks whether a positive shape-body standoff is part of an authored
471
+ * stack instead of a model-origin quirk that should be seated on the board.
472
+ * @param {object | null | undefined} componentBody Component-body row.
473
+ * @param {object | null} matchedComponent Matched owner component.
474
+ * @returns {boolean}
475
+ */
476
+ static #shouldPreservePositiveBodyStandoff(
477
+ componentBody,
478
+ matchedComponent
479
+ ) {
480
+ return (
481
+ !matchedComponent &&
482
+ PcbScene3dBuilder.#isShapeBasedComponentBody(componentBody)
483
+ )
484
+ }
485
+
486
+ /**
487
+ * Checks whether one body row came from shape-based 3D body metadata.
488
+ * @param {object | null | undefined} componentBody Component-body row.
489
+ * @returns {boolean}
490
+ */
491
+ static #isShapeBasedComponentBody(componentBody) {
492
+ return (
493
+ String(componentBody?.sourceStream || '').includes(
494
+ 'ShapeBasedComponentBodies'
495
+ ) ||
496
+ Boolean(componentBody?.staticGeometry) ||
497
+ Boolean(componentBody?.modelTypeName)
498
+ )
382
499
  }
383
500
 
384
501
  /**
@@ -64,6 +64,62 @@ export class PcbScene3dPlacementSideResolver {
64
64
  return standoffSide || 'top'
65
65
  }
66
66
 
67
+ /**
68
+ * Resolves which board side one authored static shape body should mount on.
69
+ * Shape bodies carry explicit mechanical-layer intent, so that side wins
70
+ * over loose nearby-package identity unless the body was directly matched.
71
+ * @param {{ layer?: string, positionMil?: { x?: number, y?: number }, standoffHeightMil?: number | null, overallHeightMil?: number | null }} componentBody
72
+ * @param {{ layer?: string } | null} matchedComponent
73
+ * @param {{ layer?: string, pattern?: string, source?: string, modelPath?: string, x?: number, y?: number }[]} components
74
+ * @param {{ minX?: number, minY?: number, widthMil?: number, heightMil?: number } | null} board
75
+ * @returns {'top' | 'bottom'}
76
+ */
77
+ static resolveStaticBodyPlacementSide(
78
+ componentBody,
79
+ matchedComponent,
80
+ components = [],
81
+ board = null
82
+ ) {
83
+ const matchedSide =
84
+ PcbScene3dPlacementSideResolver.#resolveComponentLayerSide(
85
+ matchedComponent?.layer
86
+ )
87
+ if (matchedSide) {
88
+ return matchedSide
89
+ }
90
+
91
+ const standoffSide =
92
+ PcbScene3dPlacementSideResolver.#resolveStandoffSide(componentBody)
93
+ if (
94
+ standoffSide &&
95
+ PcbScene3dPlacementSideResolver.#isBodyAnchorInsideBoard(
96
+ componentBody,
97
+ board
98
+ )
99
+ ) {
100
+ return standoffSide
101
+ }
102
+
103
+ const mechanicalSide =
104
+ PcbScene3dPlacementSideResolver.#resolveMechanicalLayerSide(
105
+ componentBody?.layer
106
+ )
107
+ if (mechanicalSide) {
108
+ return mechanicalSide
109
+ }
110
+
111
+ const nearbySide =
112
+ PcbScene3dPlacementSideResolver.#resolveNearbyComponentSide(
113
+ componentBody,
114
+ components
115
+ )
116
+ if (nearbySide) {
117
+ return nearbySide
118
+ }
119
+
120
+ return standoffSide || 'top'
121
+ }
122
+
67
123
  /**
68
124
  * Resolves the grouping key for repeated component-body matching.
69
125
  * @param {{ modelId?: string, name?: string, identifier?: string }} componentBody
@@ -11,12 +11,27 @@ export class PcbScene3dStaticBodyPlacementBuilder {
11
11
  static #UNMATCHED_BODY_OVERHANG_RATIO = 0.25
12
12
  static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
13
13
  static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
14
+ static #OWNER_AFFINITY_DISTANCE_MIL = 600
15
+ static #OWNER_EXACT_DISTANCE_MIL = 5
16
+ static #OWNER_EXACT_MAX_SPAN_MIL = 500
17
+ static #GENERIC_MECHANICAL_IDENTITY_TOKENS = new Set([
18
+ 'can',
19
+ 'cover',
20
+ 'enclosure',
21
+ 'frame',
22
+ 'hardware',
23
+ 'leg',
24
+ 'mechanical',
25
+ 'plate',
26
+ 'shield'
27
+ ])
14
28
 
15
29
  /**
16
30
  * 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.
31
+ * @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
32
  * @param {({ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null)[]} bodyMatches Matched components.
19
33
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
34
+ * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads Pads.
20
35
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
21
36
  * @param {number} thicknessMil Board thickness.
22
37
  * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, geometry: object }[]}
@@ -25,6 +40,7 @@ export class PcbScene3dStaticBodyPlacementBuilder {
25
40
  componentBodies,
26
41
  bodyMatches,
27
42
  components,
43
+ pads,
28
44
  board,
29
45
  thicknessMil
30
46
  ) {
@@ -41,9 +57,18 @@ export class PcbScene3dStaticBodyPlacementBuilder {
41
57
  .filter(Boolean)
42
58
  }
43
59
 
60
+ /**
61
+ * Checks whether one static geometry is already complete.
62
+ * @param {object | undefined} geometry Static geometry.
63
+ * @returns {boolean}
64
+ */
65
+ static #isCompleteGeometry(geometry) {
66
+ return Boolean(geometry && geometry.status === 'complete')
67
+ }
68
+
44
69
  /**
45
70
  * 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.
71
+ * @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
72
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent Matched component.
48
73
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components Components.
49
74
  * @param {{ centerX: number, centerY: number, minX?: number, minY?: number, widthMil?: number, heightMil?: number }} board Board.
@@ -57,9 +82,26 @@ export class PcbScene3dStaticBodyPlacementBuilder {
57
82
  board,
58
83
  thicknessMil
59
84
  ) {
60
- const geometry = componentBody?.staticGeometry
85
+ const geometry =
86
+ PcbScene3dStaticBodyPlacementBuilder.#normalizeStaticGeometry(
87
+ componentBody,
88
+ componentBody?.staticGeometry
89
+ )
61
90
 
62
- if (!geometry || geometry.status !== 'complete') {
91
+ if (
92
+ !PcbScene3dStaticBodyPlacementBuilder.#isCompleteGeometry(geometry)
93
+ ) {
94
+ return null
95
+ }
96
+
97
+ if (
98
+ !matchedComponent &&
99
+ PcbScene3dStaticBodyPlacementBuilder.#shouldSuppressStaticBody(
100
+ componentBody,
101
+ geometry,
102
+ components
103
+ )
104
+ ) {
63
105
  return null
64
106
  }
65
107
 
@@ -73,12 +115,13 @@ export class PcbScene3dStaticBodyPlacementBuilder {
73
115
  return null
74
116
  }
75
117
 
76
- const mountSide = PcbScene3dPlacementSideResolver.resolvePlacementSide(
77
- componentBody,
78
- matchedComponent,
79
- components,
80
- board
81
- )
118
+ const mountSide =
119
+ PcbScene3dPlacementSideResolver.resolveStaticBodyPlacementSide(
120
+ componentBody,
121
+ matchedComponent,
122
+ components,
123
+ board
124
+ )
82
125
  const sourcePosition =
83
126
  PcbScene3dStaticBodyPlacementBuilder.#sourcePosition(componentBody)
84
127
  const heightMil =
@@ -119,10 +162,283 @@ export class PcbScene3dStaticBodyPlacementBuilder {
119
162
  x: Number(componentBody.positionMil?.x || 0),
120
163
  y: Number(componentBody.positionMil?.y || 0)
121
164
  },
165
+ ...PcbScene3dStaticBodyPlacementBuilder.#displayMetadata(
166
+ componentBody
167
+ ),
122
168
  geometry
123
169
  }
124
170
  }
125
171
 
172
+ /**
173
+ * Converts source-space polygon vertices into render-local geometry.
174
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
175
+ * @param {object | undefined} geometry Static geometry.
176
+ * @returns {object | undefined}
177
+ */
178
+ static #normalizeStaticGeometry(componentBody, geometry) {
179
+ if (
180
+ String(geometry?.kind || '').toLowerCase() !== 'extruded-polygon' ||
181
+ !Array.isArray(geometry?.verticesMil) ||
182
+ geometry.verticesMil.length < 3 ||
183
+ !PcbScene3dStaticBodyPlacementBuilder.#usesSourceCoordinateFrame(
184
+ componentBody,
185
+ geometry.verticesMil
186
+ )
187
+ ) {
188
+ return geometry
189
+ }
190
+
191
+ const center =
192
+ PcbScene3dStaticBodyPlacementBuilder.#polygonBoundsCenter(
193
+ geometry.verticesMil
194
+ )
195
+
196
+ return {
197
+ ...geometry,
198
+ verticesMil: geometry.verticesMil.map((vertex) => ({
199
+ x: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
200
+ Number(vertex?.x || 0) - center.x
201
+ ),
202
+ y: PcbScene3dStaticBodyPlacementBuilder.#roundMil(
203
+ Number(vertex?.y || 0) - center.y
204
+ )
205
+ }))
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Checks whether polygon vertices use board/source coordinates instead of
211
+ * small body-local coordinates.
212
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
213
+ * @param {{ x?: number, y?: number }[]} vertices Vertices.
214
+ * @returns {boolean}
215
+ */
216
+ static #usesSourceCoordinateFrame(componentBody, vertices) {
217
+ const center =
218
+ PcbScene3dStaticBodyPlacementBuilder.#polygonBoundsCenter(vertices)
219
+ const source =
220
+ PcbScene3dStaticBodyPlacementBuilder.#sourcePosition(componentBody)
221
+
222
+ return (
223
+ Math.max(
224
+ Math.abs(center.x),
225
+ Math.abs(center.y),
226
+ Math.abs(source.x),
227
+ Math.abs(source.y)
228
+ ) > 1000
229
+ )
230
+ }
231
+
232
+ /**
233
+ * Resolves the axis-aligned polygon bounds center.
234
+ * @param {{ x?: number, y?: number }[]} vertices Vertices.
235
+ * @returns {{ x: number, y: number }}
236
+ */
237
+ static #polygonBoundsCenter(vertices) {
238
+ const points = (Array.isArray(vertices) ? vertices : []).map(
239
+ (vertex) => ({
240
+ x: Number(vertex?.x || 0),
241
+ y: Number(vertex?.y || 0)
242
+ })
243
+ )
244
+ const xs = points.map((point) => point.x)
245
+ const ys = points.map((point) => point.y)
246
+
247
+ return {
248
+ x: (Math.min(...xs) + Math.max(...xs)) / 2,
249
+ y: (Math.min(...ys) + Math.max(...ys)) / 2
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Resolves optional display metadata for static body rendering.
255
+ * @param {{ bodyColor?: object, bodyOpacity?: number | string }} componentBody Component body.
256
+ * @returns {{ bodyColor?: object, bodyOpacity?: number }}
257
+ */
258
+ static #displayMetadata(componentBody) {
259
+ const metadata = {}
260
+ if (
261
+ componentBody?.bodyColor &&
262
+ typeof componentBody.bodyColor === 'object'
263
+ ) {
264
+ metadata.bodyColor = componentBody.bodyColor
265
+ }
266
+
267
+ const opacity = Number(componentBody?.bodyOpacity)
268
+ if (Number.isFinite(opacity)) {
269
+ metadata.bodyOpacity = opacity
270
+ }
271
+
272
+ return metadata
273
+ }
274
+
275
+ /**
276
+ * Checks whether an unowned static body is likely a translucent mechanical
277
+ * overlay instead of a component package.
278
+ * @param {{ componentIndex?: number, embedded?: boolean, bodyOpacity?: number | string, identifier?: string, name?: string, positionMil?: { x?: number, y?: number } }} componentBody Component body.
279
+ * @param {object} geometry Static geometry.
280
+ * @param {{ componentIndex?: number, x?: number, y?: number, pattern?: string, source?: string, modelPath?: string, description?: string, parameters?: object, provenance?: object }[]} components Components.
281
+ * @returns {boolean}
282
+ */
283
+ static #shouldSuppressStaticBody(componentBody, geometry, components) {
284
+ return (
285
+ PcbScene3dStaticBodyPlacementBuilder.#isTranslucentBody(
286
+ componentBody
287
+ ) &&
288
+ !PcbScene3dStaticBodyPlacementBuilder.#hasLikelyComponentOwner(
289
+ componentBody,
290
+ geometry,
291
+ components
292
+ )
293
+ )
294
+ }
295
+
296
+ /**
297
+ * Checks whether a body was authored with visible partial transparency.
298
+ * @param {{ bodyOpacity?: number | string }} componentBody Component body.
299
+ * @returns {boolean}
300
+ */
301
+ static #isTranslucentBody(componentBody) {
302
+ const opacity = Number(componentBody?.bodyOpacity)
303
+
304
+ return Number.isFinite(opacity) && opacity >= 0 && opacity < 1
305
+ }
306
+
307
+ /**
308
+ * Checks whether an otherwise unmatched body still has a plausible owning
309
+ * component.
310
+ * @param {{ componentIndex?: number, embedded?: boolean, identifier?: string, name?: string, positionMil?: { x?: number, y?: number } }} componentBody Component body.
311
+ * @param {object} geometry Static geometry.
312
+ * @param {{ componentIndex?: number, x?: number, y?: number, pattern?: string, source?: string, modelPath?: string, description?: string, parameters?: object, provenance?: object }[]} components Components.
313
+ * @returns {boolean}
314
+ */
315
+ static #hasLikelyComponentOwner(componentBody, geometry, components) {
316
+ if (componentBody?.embedded) {
317
+ return true
318
+ }
319
+
320
+ const componentIndex = Number(componentBody?.componentIndex)
321
+ if (
322
+ Number.isInteger(componentIndex) &&
323
+ (Array.isArray(components) ? components : []).some(
324
+ (component) =>
325
+ Number(component?.componentIndex) === componentIndex
326
+ )
327
+ ) {
328
+ return true
329
+ }
330
+
331
+ const maxSpanMil =
332
+ PcbScene3dStaticBodyPlacementBuilder.#geometryMaxSpan(geometry)
333
+ const genericMechanical =
334
+ PcbScene3dStaticBodyPlacementBuilder.#hasGenericMechanicalIdentity(
335
+ componentBody
336
+ )
337
+
338
+ return (Array.isArray(components) ? components : []).some(
339
+ (component) => {
340
+ const distance =
341
+ PcbScene3dStaticBodyPlacementBuilder.#distanceBetweenBodyAndComponent(
342
+ componentBody,
343
+ component
344
+ )
345
+ const affinityScore =
346
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
347
+ componentBody,
348
+ component
349
+ )
350
+
351
+ return (
352
+ (!genericMechanical &&
353
+ affinityScore > 0 &&
354
+ distance <=
355
+ PcbScene3dStaticBodyPlacementBuilder
356
+ .#OWNER_AFFINITY_DISTANCE_MIL) ||
357
+ (!genericMechanical &&
358
+ distance <=
359
+ PcbScene3dStaticBodyPlacementBuilder
360
+ .#OWNER_EXACT_DISTANCE_MIL &&
361
+ maxSpanMil <=
362
+ PcbScene3dStaticBodyPlacementBuilder
363
+ .#OWNER_EXACT_MAX_SPAN_MIL)
364
+ )
365
+ }
366
+ )
367
+ }
368
+
369
+ /**
370
+ * Checks for generic mechanical labels that are too weak to prove package
371
+ * ownership for translucent static bodies.
372
+ * @param {{ identifier?: string, name?: string }} componentBody Component body.
373
+ * @returns {boolean}
374
+ */
375
+ static #hasGenericMechanicalIdentity(componentBody) {
376
+ return PcbScene3dStaticBodyPlacementBuilder.#identityTokens(
377
+ componentBody
378
+ ).some((token) =>
379
+ PcbScene3dStaticBodyPlacementBuilder.#GENERIC_MECHANICAL_IDENTITY_TOKENS.has(
380
+ token
381
+ )
382
+ )
383
+ }
384
+
385
+ /**
386
+ * Collects normalized identity tokens from one body row.
387
+ * @param {{ identifier?: string, name?: string }} componentBody Component body.
388
+ * @returns {string[]}
389
+ */
390
+ static #identityTokens(componentBody) {
391
+ return [componentBody?.identifier, componentBody?.name]
392
+ .join(' ')
393
+ .toLowerCase()
394
+ .split(/[^a-z0-9]+/g)
395
+ .flatMap((fragment) => fragment.match(/[a-z]+|\d+/g) || [])
396
+ .filter(Boolean)
397
+ }
398
+
399
+ /**
400
+ * Resolves the largest horizontal span of a static body.
401
+ * @param {object} geometry Static geometry.
402
+ * @returns {number}
403
+ */
404
+ static #geometryMaxSpan(geometry) {
405
+ if (
406
+ Array.isArray(geometry?.verticesMil) &&
407
+ geometry.verticesMil.length
408
+ ) {
409
+ const points = geometry.verticesMil.map((vertex) => ({
410
+ x: Number(vertex?.x || 0),
411
+ y: Number(vertex?.y || 0)
412
+ }))
413
+ const xs = points.map((point) => point.x)
414
+ const ys = points.map((point) => point.y)
415
+
416
+ return Math.max(
417
+ Math.max(...xs) - Math.min(...xs),
418
+ Math.max(...ys) - Math.min(...ys)
419
+ )
420
+ }
421
+
422
+ const radius = Number(geometry?.radiusMil)
423
+
424
+ return Number.isFinite(radius) && radius > 0 ? radius * 2 : 0
425
+ }
426
+
427
+ /**
428
+ * Returns the euclidean distance between body and component anchors.
429
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.
430
+ * @param {{ x?: number, y?: number }} component Component.
431
+ * @returns {number}
432
+ */
433
+ static #distanceBetweenBodyAndComponent(componentBody, component) {
434
+ return Math.hypot(
435
+ Number(component?.x || 0) -
436
+ Number(componentBody?.positionMil?.x || 0),
437
+ Number(component?.y || 0) -
438
+ Number(componentBody?.positionMil?.y || 0)
439
+ )
440
+ }
441
+
126
442
  /**
127
443
  * Returns the native body anchor.
128
444
  * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component body.