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
@@ -9,13 +9,16 @@ 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'
15
16
  import { PcbScene3dPlacementSideResolver } from './PcbScene3dPlacementSideResolver.mjs'
16
17
  import { PcbScene3dStaticBodyPlacementBuilder } from './PcbScene3dStaticBodyPlacementBuilder.mjs'
18
+ import { PcbScene3dPadYawResolver } from './PcbScene3dPadYawResolver.mjs'
17
19
  import { PcbScene3dTextBoxLayoutResolver } from './PcbScene3dTextBoxLayoutResolver.mjs'
18
20
  import { PcbFootprintPadAxisNormalizer } from './PcbFootprintPadAxisNormalizer.mjs'
21
+ import { PcbScene3dCopperRegionDetailBuilder } from './PcbScene3dCopperRegionDetailBuilder.mjs'
19
22
 
20
23
  /**
21
24
  * Builds deterministic 3D scene data from the normalized PCB model.
@@ -31,11 +34,27 @@ export class PcbScene3dBuilder {
31
34
  static #UNMATCHED_BODY_OVERHANG_RATIO = 0.25
32
35
  static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
33
36
  static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
37
+ static #OVERSIZED_GENERIC_FALLBACK_MAX_MIL = 800
38
+ static #TIMING_STACK_BODY_RADIUS_MIL = 220
34
39
  static #TRUETYPE_TEXT_WIDTH_RATIO = 0.55
40
+ static #LOW_CONFIDENCE_GENERIC_FOOTPRINT_PATTERN =
41
+ /(?:^|[^a-z0-9])(?:edge|finger|fingers|contact|contacts|mech|mechanical|jumper|jump)(?:$|[^a-z0-9])/i
35
42
  static #AUTHORED_BODY_IDENTITY_PATTERN =
36
43
  /(?:^|[^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
44
+ static #AUTHORED_COVER_STACK_IDENTITY_PATTERN =
45
+ /(?:^|[^a-z0-9])(?:emi|rf|rfi|shield|cover|can)(?:$|[^a-z0-9])/i
46
+ static #MECHANICAL_SHIELD_FALLBACK_PATTERN =
47
+ /(?:^|[^a-z0-9])(?:emi|rfi|shield|cover|can)(?:$|[^a-z0-9])/i
48
+ static #MECHANICAL_SHIELD_FRAME_OWNER_PATTERN =
49
+ /(?=.*(?:^|[^a-z0-9])(?:emi|rfi|rf|shield|can)(?:$|[^a-z0-9]))(?=.*(?:^|[^a-z0-9])frame(?:$|[^a-z0-9]))/i
50
+ static #MECHANICAL_SHIELD_FRAME_BODY_PATTERN =
51
+ /(?:^|[^a-z0-9])(?:frame[0-9]*|leg|rail|side|wall)(?:$|[^a-z0-9])/i
52
+ static #MECHANICAL_SHIELD_FRAME_OWNER_RADIUS_MIL = 750
37
53
  static #COMPONENT_PACKAGE_BODY_PATTERN =
38
54
  /(?:^|[^a-z0-9])(?:[a-z0-9]*dfn|[a-z0-9]*qfn|bga|cap|capacitor|crystal|diode|ferrite|ind|inductor|lga|lqg[a-z0-9]*|lqw[a-z0-9]*|osc|qfp|res|resistor|sot|transistor|xtal)(?:$|[^a-z0-9])/i
55
+ static #TIMING_STACK_COMPONENT_PATTERN =
56
+ /(?:^|[^a-z0-9])(?:clock|crystal|osc|oscillator|resonator|tcxo|txco|xtal)(?:$|[^a-z0-9])/i
57
+ static #TIMING_STACK_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
39
58
 
40
59
  /**
41
60
  * Builds a scene description for host 3D renderers.
@@ -60,6 +79,7 @@ export class PcbScene3dBuilder {
60
79
  const tracks = Array.isArray(pcb.tracks) ? pcb.tracks : []
61
80
  const arcs = Array.isArray(pcb.arcs) ? pcb.arcs : []
62
81
  const fills = Array.isArray(pcb.fills) ? pcb.fills : []
82
+ const regionFills = PcbScene3dCopperRegionDetailBuilder.build(pcb)
63
83
  const texts = Array.isArray(pcb.texts) ? pcb.texts : []
64
84
  const vias = Array.isArray(pcb.vias) ? pcb.vias : []
65
85
  const silkscreenRegions =
@@ -87,6 +107,7 @@ export class PcbScene3dBuilder {
87
107
  : []
88
108
  }
89
109
  const componentBodyModels = componentBodies.map((componentBody) =>
110
+ PcbScene3dBuilder.#shouldRenderStaticGeometryOnly(componentBody) ||
90
111
  PcbScene3dBuilder.#shouldSuppressLayerlessBodyPlaceholder(
91
112
  componentBody,
92
113
  componentBodies,
@@ -141,15 +162,17 @@ export class PcbScene3dBuilder {
141
162
  boardAssemblyModel:
142
163
  modelRegistry?.resolveBoardAssemblyModel?.(documentModel) ||
143
164
  null,
144
- components: components.map((component) =>
145
- PcbScene3dBuilder.#buildComponent(
146
- component,
147
- pads,
148
- board,
149
- thicknessMil,
150
- modelRegistry
165
+ components: components
166
+ .map((component) =>
167
+ PcbScene3dBuilder.#buildComponent(
168
+ component,
169
+ pads,
170
+ board,
171
+ thicknessMil,
172
+ modelRegistry
173
+ )
151
174
  )
152
- ),
175
+ .filter(Boolean),
153
176
  externalPlacements: componentBodies
154
177
  .map((componentBody, index) =>
155
178
  PcbScene3dBuilder.#buildExternalPlacement(
@@ -167,6 +190,7 @@ export class PcbScene3dBuilder {
167
190
  componentBodies,
168
191
  bodyMatches,
169
192
  components,
193
+ pads,
170
194
  board,
171
195
  thicknessMil
172
196
  ),
@@ -177,7 +201,7 @@ export class PcbScene3dBuilder {
177
201
  pads,
178
202
  tracks,
179
203
  arcs,
180
- fills,
204
+ fills: [...fills, ...regionFills],
181
205
  vias,
182
206
  polygons: Array.isArray(pcb.polygons) ? pcb.polygons : [],
183
207
  silkscreen: {
@@ -191,8 +215,11 @@ export class PcbScene3dBuilder {
191
215
  AltiumScene3dBottomPadRotationAdapter.apply(
192
216
  AltiumScene3dComponentBodyAdapter.apply(
193
217
  AltiumScene3dExternalPlacementAdapter.apply(
194
- PcbScene3dBoardOutlineRefiner.refine(
195
- sceneDescription,
218
+ AltiumScene3dShapeStackOwnerAdapter.apply(
219
+ PcbScene3dBoardOutlineRefiner.refine(
220
+ sceneDescription,
221
+ sceneDocumentModel
222
+ ),
196
223
  sceneDocumentModel
197
224
  ),
198
225
  sceneDocumentModel
@@ -210,7 +237,7 @@ export class PcbScene3dBuilder {
210
237
  * @param {{ centerX: number, centerY: number }} board
211
238
  * @param {number} thicknessMil
212
239
  * @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 }}
240
+ * @returns {{ componentIndex: number | null, 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, renderFallbackBody?: boolean } | null}
214
241
  */
215
242
  static #buildComponent(
216
243
  component,
@@ -220,8 +247,27 @@ export class PcbScene3dBuilder {
220
247
  modelRegistry
221
248
  ) {
222
249
  const mountSide = PcbScene3dBuilder.#resolveMountSide(component)
223
- const padSpan = PcbScene3dBuilder.#resolvePadSpan(component, pads)
250
+ const rotationDeg = PcbScene3dBuilder.#resolveComponentRotation(
251
+ component,
252
+ pads,
253
+ mountSide
254
+ )
255
+ const padSpan = PcbScene3dBuilder.#resolvePadSpan(
256
+ component,
257
+ pads,
258
+ rotationDeg
259
+ )
224
260
  const body = PcbScene3dPackages.resolve(component, padSpan)
261
+ const externalModel = modelRegistry
262
+ ? modelRegistry.resolveComponentModel(component)
263
+ : null
264
+ const suppressFallbackBody =
265
+ PcbScene3dBuilder.#shouldSuppressProceduralComponent(
266
+ component,
267
+ body,
268
+ externalModel
269
+ )
270
+
225
271
  const halfBoardThickness = thicknessMil / 2
226
272
  const halfBodyHeight = body.sizeMil.height / 2
227
273
  const z =
@@ -230,9 +276,12 @@ export class PcbScene3dBuilder {
230
276
  : halfBoardThickness + halfBodyHeight
231
277
 
232
278
  return {
279
+ componentIndex: Number.isFinite(Number(component.componentIndex))
280
+ ? Number(component.componentIndex)
281
+ : null,
233
282
  designator: component.designator,
234
283
  mountSide,
235
- rotationDeg: Number(component.rotation || 0),
284
+ rotationDeg,
236
285
  positionMil: {
237
286
  x: Number(component.x || 0) - Number(board.centerX || 0),
238
287
  y: Number(component.y || 0) - Number(board.centerY || 0),
@@ -253,12 +302,90 @@ export class PcbScene3dBuilder {
253
302
  ? { ...component.parameters }
254
303
  : {},
255
304
  body,
256
- externalModel: modelRegistry
257
- ? modelRegistry.resolveComponentModel(component)
258
- : null
305
+ externalModel,
306
+ ...(suppressFallbackBody ? { renderFallbackBody: false } : {})
259
307
  }
260
308
  }
261
309
 
310
+ /**
311
+ * Checks whether one generated fallback body is too uncertain to render.
312
+ * @param {{ pattern?: string, source?: string, description?: string }} component Source component.
313
+ * @param {{ family?: string, sizeMil?: { width?: number, depth?: number } }} body Procedural body.
314
+ * @param {object | null} externalModel Resolved external model.
315
+ * @returns {boolean}
316
+ */
317
+ static #shouldSuppressProceduralComponent(component, body, externalModel) {
318
+ return (
319
+ !externalModel &&
320
+ body?.family === 'generic' &&
321
+ ((PcbScene3dBuilder.#isOversizedGenericFallback(body) &&
322
+ PcbScene3dBuilder.#isLowConfidenceGenericFootprint(
323
+ component
324
+ )) ||
325
+ PcbScene3dBuilder.#isMechanicalShieldFallback(component))
326
+ )
327
+ }
328
+
329
+ /**
330
+ * Checks whether a generic component row describes authored shield
331
+ * hardware that should not become a filled fallback box.
332
+ * @param {{ pattern?: string, source?: string, description?: string, parameters?: Record<string, unknown>, provenance?: Record<string, unknown> }} component Source component.
333
+ * @returns {boolean}
334
+ */
335
+ static #isMechanicalShieldFallback(component) {
336
+ return PcbScene3dBuilder.#MECHANICAL_SHIELD_FALLBACK_PATTERN.test(
337
+ PcbScene3dBuilder.#componentIdentityText(component)
338
+ )
339
+ }
340
+
341
+ /**
342
+ * Builds normalized free-text identity for component classification.
343
+ * @param {{ pattern?: string, source?: string, description?: string, parameters?: Record<string, unknown>, provenance?: Record<string, unknown> }} component Source component.
344
+ * @returns {string}
345
+ */
346
+ static #componentIdentityText(component) {
347
+ return [
348
+ component?.pattern,
349
+ component?.source,
350
+ component?.description,
351
+ component?.provenance?.sourceLibReference,
352
+ component?.provenance?.footprintDescription,
353
+ ...Object.values(component?.parameters || {})
354
+ ]
355
+ .map((value) => String(value || ''))
356
+ .join(' ')
357
+ }
358
+
359
+ /**
360
+ * Checks whether one generic fallback body spans too much of the board.
361
+ * @param {{ sizeMil?: { width?: number, depth?: number } }} body Procedural body.
362
+ * @returns {boolean}
363
+ */
364
+ static #isOversizedGenericFallback(body) {
365
+ return (
366
+ Math.max(
367
+ Number(body?.sizeMil?.width || 0),
368
+ Number(body?.sizeMil?.depth || 0)
369
+ ) > PcbScene3dBuilder.#OVERSIZED_GENERIC_FALLBACK_MAX_MIL
370
+ )
371
+ }
372
+
373
+ /**
374
+ * Checks for footprint identities that describe board features or
375
+ * mechanical placeholders more often than physical package bodies.
376
+ * @param {{ pattern?: string, source?: string, description?: string }} component Source component.
377
+ * @returns {boolean}
378
+ */
379
+ static #isLowConfidenceGenericFootprint(component) {
380
+ return PcbScene3dBuilder.#LOW_CONFIDENCE_GENERIC_FOOTPRINT_PATTERN.test(
381
+ [
382
+ component?.pattern,
383
+ component?.source,
384
+ component?.description
385
+ ].join(' ')
386
+ )
387
+ }
388
+
262
389
  /**
263
390
  * Resolves one component-body model through the active registry.
264
391
  * @param {{ modelId?: string, checksum?: number | null, name?: string }} componentBody Component body metadata.
@@ -272,7 +399,7 @@ export class PcbScene3dBuilder {
272
399
  /**
273
400
  * Builds one explicit external-model placement from normalized component
274
401
  * body metadata.
275
- * @param {{ modelId?: string, checksum?: number | null, embedded?: boolean, name?: string, identifier?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, modelRotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number }} componentBody
402
+ * @param {{ modelId?: string, checksum?: number | null, embedded?: boolean, name?: string, identifier?: string, layer?: string, positionMil?: { x?: number, y?: number }, rotationDeg?: number, modelRotationDeg?: { x?: number, y?: number, z?: number }, dzMil?: number, bodyOpacity?: number | string }} componentBody
276
403
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent
277
404
  * @param {{ origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null} resolvedModel
278
405
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components
@@ -290,10 +417,24 @@ export class PcbScene3dBuilder {
290
417
  board,
291
418
  thicknessMil
292
419
  ) {
420
+ if (PcbScene3dBuilder.#shouldRenderStaticGeometryOnly(componentBody)) {
421
+ return null
422
+ }
423
+
293
424
  if (!resolvedModel) {
294
425
  return null
295
426
  }
296
427
 
428
+ if (
429
+ PcbScene3dBuilder.#isPositiveTimingStackPackageBody(
430
+ componentBody,
431
+ matchedComponent,
432
+ components
433
+ )
434
+ ) {
435
+ return null
436
+ }
437
+
297
438
  if (
298
439
  !matchedComponent &&
299
440
  PcbScene3dBuilder.#shouldDropUnmatchedPackageBody(
@@ -352,7 +493,8 @@ export class PcbScene3dBuilder {
352
493
  modelTransform: {
353
494
  rotationDeg: modelRotation,
354
495
  dzMil: PcbScene3dBuilder.#resolveComponentBodyVerticalOffset(
355
- componentBody
496
+ componentBody,
497
+ matchedComponent
356
498
  )
357
499
  },
358
500
  projection: PcbScene3dBuilder.#resolveProjectionDiagnostics(
@@ -361,24 +503,126 @@ export class PcbScene3dBuilder {
361
503
  pads,
362
504
  resolvedModel
363
505
  ),
506
+ ...PcbScene3dBuilder.#componentBodyDisplayMetadata(componentBody),
364
507
  externalModel: resolvedModel
365
508
  }
366
509
  }
367
510
 
511
+ /**
512
+ * Resolves optional component-body display metadata for external renderers.
513
+ * @param {{ bodyOpacity?: number | string }} componentBody Component body.
514
+ * @returns {{ bodyOpacity?: number }}
515
+ */
516
+ static #componentBodyDisplayMetadata(componentBody) {
517
+ const opacity = Number(componentBody?.bodyOpacity)
518
+
519
+ return Number.isFinite(opacity) && opacity > 0 && opacity < 1
520
+ ? { bodyOpacity: opacity }
521
+ : {}
522
+ }
523
+
368
524
  /**
369
525
  * Resolves the vertical offset that should remain after the viewer seats
370
526
  * raw model bounds on the board face.
371
527
  * @param {{ dzMil?: number, standoffHeightMil?: number | null }} componentBody Component-body placement metadata.
528
+ * @param {object | null} matchedComponent Matched owner component.
372
529
  * @returns {number}
373
530
  */
374
- static #resolveComponentBodyVerticalOffset(componentBody) {
531
+ static #resolveComponentBodyVerticalOffset(
532
+ componentBody,
533
+ matchedComponent = null
534
+ ) {
375
535
  const standoffHeightMil = Number(componentBody?.standoffHeightMil)
376
536
  if (Number.isFinite(standoffHeightMil)) {
377
- return standoffHeightMil < 0 ? standoffHeightMil : 0
537
+ return standoffHeightMil < 0 ||
538
+ PcbScene3dBuilder.#shouldPreservePositiveBodyStandoff(
539
+ componentBody,
540
+ matchedComponent
541
+ )
542
+ ? standoffHeightMil
543
+ : 0
378
544
  }
379
545
 
380
546
  const dzMil = Number(componentBody?.dzMil)
381
- return Number.isFinite(dzMil) && dzMil < 0 ? dzMil : 0
547
+ return Number.isFinite(dzMil) &&
548
+ (dzMil < 0 ||
549
+ PcbScene3dBuilder.#shouldPreservePositiveBodyStandoff(
550
+ componentBody,
551
+ matchedComponent
552
+ ))
553
+ ? dzMil
554
+ : 0
555
+ }
556
+
557
+ /**
558
+ * Checks whether a positive shape-body standoff is part of an authored
559
+ * stack instead of a model-origin quirk that should be seated on the board.
560
+ * @param {object | null | undefined} componentBody Component-body row.
561
+ * @param {object | null} matchedComponent Matched owner component.
562
+ * @returns {boolean}
563
+ */
564
+ static #shouldPreservePositiveBodyStandoff(
565
+ componentBody,
566
+ matchedComponent
567
+ ) {
568
+ return (
569
+ (!matchedComponent &&
570
+ PcbScene3dBuilder.#isShapeBasedComponentBody(componentBody)) ||
571
+ PcbScene3dBuilder.#hasAuthoredCoverStackStandoff(
572
+ componentBody,
573
+ matchedComponent
574
+ )
575
+ )
576
+ }
577
+
578
+ /**
579
+ * Checks whether a positive standoff describes a real mechanical cover
580
+ * stack instead of an embedded model source-origin air gap.
581
+ * @param {object | null | undefined} componentBody Component-body row.
582
+ * @param {object | null} matchedComponent Matched owner component.
583
+ * @returns {boolean}
584
+ */
585
+ static #hasAuthoredCoverStackStandoff(componentBody, matchedComponent) {
586
+ if (!matchedComponent || !componentBody?.embedded) {
587
+ return false
588
+ }
589
+
590
+ const standoff = Number(componentBody?.standoffHeightMil)
591
+ const overallHeight = Number(componentBody?.overallHeightMil)
592
+ if (
593
+ !Number.isFinite(standoff) ||
594
+ !Number.isFinite(overallHeight) ||
595
+ standoff <= 0 ||
596
+ overallHeight <= standoff
597
+ ) {
598
+ return false
599
+ }
600
+
601
+ const identityText = [
602
+ componentBody?.identifier,
603
+ componentBody?.name,
604
+ matchedComponent?.pattern,
605
+ matchedComponent?.source
606
+ ].join(' ')
607
+
608
+ return PcbScene3dBuilder.#AUTHORED_COVER_STACK_IDENTITY_PATTERN.test(
609
+ identityText
610
+ )
611
+ }
612
+
613
+ /**
614
+ * Checks whether one body row came from shape-based 3D body metadata.
615
+ * @param {object | null | undefined} componentBody Component-body row.
616
+ * @returns {boolean}
617
+ */
618
+ static #isShapeBasedComponentBody(componentBody) {
619
+ return (
620
+ String(componentBody?.sourceStream || '').includes(
621
+ 'ShapeBasedComponentBodies'
622
+ ) ||
623
+ Boolean(componentBody?.staticGeometry) ||
624
+ Boolean(componentBody?.modelTypeName)
625
+ )
382
626
  }
383
627
 
384
628
  /**
@@ -526,7 +770,8 @@ export class PcbScene3dBuilder {
526
770
 
527
771
  componentBodies.forEach((componentBody, bodyIndex) => {
528
772
  if (
529
- !PcbScene3dBuilder.#isResolvableComponentBody(
773
+ !PcbScene3dBuilder.#isMatchableComponentBody(
774
+ componentBody,
530
775
  resolvedBodyModels,
531
776
  bodyIndex
532
777
  )
@@ -613,7 +858,8 @@ export class PcbScene3dBuilder {
613
858
  const groupedBodyIndexes = new Map()
614
859
  componentBodies.forEach((componentBody, bodyIndex) => {
615
860
  if (
616
- !PcbScene3dBuilder.#isResolvableComponentBody(
861
+ !PcbScene3dBuilder.#isMatchableComponentBody(
862
+ componentBody,
617
863
  resolvedBodyModels,
618
864
  bodyIndex
619
865
  )
@@ -682,9 +928,115 @@ export class PcbScene3dBuilder {
682
928
  })
683
929
  })
684
930
 
931
+ PcbScene3dBuilder.#assignStaticShieldFrameBodyMatches(
932
+ matches,
933
+ componentBodies,
934
+ components
935
+ )
936
+
685
937
  return matches
686
938
  }
687
939
 
940
+ /**
941
+ * Assigns static shield-frame sub-bodies to their nearest shield-frame
942
+ * component owner. These bodies are renderable without external models, so
943
+ * the external-model ownership pass does not see them.
944
+ * @param {({ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string } | null)[]} matches Mutable match array.
945
+ * @param {{ identifier?: string, name?: string, layer?: string, positionMil?: { x?: number, y?: number }, staticGeometry?: object }[]} componentBodies Component bodies.
946
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components PCB components.
947
+ */
948
+ static #assignStaticShieldFrameBodyMatches(
949
+ matches,
950
+ componentBodies,
951
+ components
952
+ ) {
953
+ componentBodies.forEach((componentBody, bodyIndex) => {
954
+ if (
955
+ matches[bodyIndex] ||
956
+ !PcbScene3dBuilder.#isStaticShieldFrameBody(componentBody)
957
+ ) {
958
+ return
959
+ }
960
+
961
+ const owner = components
962
+ .filter(
963
+ (component) =>
964
+ PcbScene3dBuilder.#isMechanicalShieldFrameOwner(
965
+ component
966
+ ) &&
967
+ PcbScene3dBuilder.#isBodyComponentSideCompatible(
968
+ componentBody,
969
+ component
970
+ )
971
+ )
972
+ .map((component) => ({
973
+ component,
974
+ affinityScore:
975
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
976
+ componentBody,
977
+ component
978
+ ),
979
+ distance:
980
+ PcbScene3dBuilder.#distanceBetweenBodyAndComponent(
981
+ componentBody,
982
+ component
983
+ )
984
+ }))
985
+ .filter(
986
+ ({ distance }) =>
987
+ Number.isFinite(distance) &&
988
+ distance <=
989
+ PcbScene3dBuilder
990
+ .#MECHANICAL_SHIELD_FRAME_OWNER_RADIUS_MIL
991
+ )
992
+ .sort(
993
+ (left, right) =>
994
+ right.affinityScore - left.affinityScore ||
995
+ left.distance - right.distance
996
+ )[0]?.component
997
+
998
+ if (owner) {
999
+ matches[bodyIndex] = owner
1000
+ }
1001
+ })
1002
+ }
1003
+
1004
+ /**
1005
+ * Checks whether one component is a mechanical shield-frame owner.
1006
+ * @param {{ pattern?: string, source?: string, description?: string, parameters?: Record<string, unknown>, provenance?: Record<string, unknown> }} component Source component.
1007
+ * @returns {boolean}
1008
+ */
1009
+ static #isMechanicalShieldFrameOwner(component) {
1010
+ return PcbScene3dBuilder.#MECHANICAL_SHIELD_FRAME_OWNER_PATTERN.test(
1011
+ PcbScene3dBuilder.#componentIdentityText(component)
1012
+ )
1013
+ }
1014
+
1015
+ /**
1016
+ * Checks whether one static body is a shield-frame sub-body.
1017
+ * @param {{ identifier?: string, name?: string, staticGeometry?: object }} componentBody Component body.
1018
+ * @returns {boolean}
1019
+ */
1020
+ static #isStaticShieldFrameBody(componentBody) {
1021
+ const geometry = componentBody?.staticGeometry || {}
1022
+ const completeGeometry =
1023
+ geometry.status === 'complete' &&
1024
+ Array.isArray(geometry.verticesMil) &&
1025
+ geometry.verticesMil.length >= 3
1026
+ const recoverableGeometry =
1027
+ geometry.status !== 'complete' && Number(geometry.heightMil) > 0
1028
+
1029
+ return (
1030
+ String(geometry.kind || '').toLowerCase() === 'extruded-polygon' &&
1031
+ (completeGeometry || recoverableGeometry) &&
1032
+ PcbScene3dBuilder.#MECHANICAL_SHIELD_FRAME_BODY_PATTERN.test(
1033
+ [componentBody?.identifier, componentBody?.name]
1034
+ .map((value) => String(value || ''))
1035
+ .join(' ')
1036
+ )
1037
+ )
1038
+ }
1039
+
688
1040
  /**
689
1041
  * Builds reusable identity statistics for body/component matching.
690
1042
  * @param {{ modelId?: string, name?: string, identifier?: string }[]} componentBodies
@@ -703,7 +1055,8 @@ export class PcbScene3dBuilder {
703
1055
 
704
1056
  componentBodies.forEach((componentBody, bodyIndex) => {
705
1057
  if (
706
- !PcbScene3dBuilder.#isResolvableComponentBody(
1058
+ !PcbScene3dBuilder.#isMatchableComponentBody(
1059
+ componentBody,
707
1060
  resolvedBodyModels,
708
1061
  bodyIndex
709
1062
  )
@@ -740,6 +1093,29 @@ export class PcbScene3dBuilder {
740
1093
  return { bodyGroupCounts, candidateComponentCounts }
741
1094
  }
742
1095
 
1096
+ /**
1097
+ * Returns true when one body row can participate in owner matching.
1098
+ * @param {object | null | undefined} componentBody Component body row.
1099
+ * @param {unknown[]} resolvedBodyModels Resolved body-model entries.
1100
+ * @param {number} bodyIndex Body index.
1101
+ * @returns {boolean}
1102
+ */
1103
+ static #isMatchableComponentBody(
1104
+ componentBody,
1105
+ resolvedBodyModels,
1106
+ bodyIndex
1107
+ ) {
1108
+ return (
1109
+ PcbScene3dBuilder.#isResolvableComponentBody(
1110
+ resolvedBodyModels,
1111
+ bodyIndex
1112
+ ) ||
1113
+ PcbScene3dBuilder.#isAnonymousLayerlessStaticBodyGeometry(
1114
+ componentBody
1115
+ )
1116
+ )
1117
+ }
1118
+
743
1119
  /**
744
1120
  * Returns true when one body row can produce a renderable external model.
745
1121
  * @param {unknown[]} resolvedBodyModels Resolved body-model entries.
@@ -754,6 +1130,26 @@ export class PcbScene3dBuilder {
754
1130
  )
755
1131
  }
756
1132
 
1133
+ /**
1134
+ * Checks whether one anonymous layerless body row already carries
1135
+ * renderable static geometry.
1136
+ * @param {object | null | undefined} componentBody Component body row.
1137
+ * @returns {boolean}
1138
+ */
1139
+ static #isAnonymousLayerlessStaticBodyGeometry(componentBody) {
1140
+ const identityText = [componentBody?.identifier, componentBody?.name]
1141
+ .map((value) => String(value || '').trim())
1142
+ .join('')
1143
+
1144
+ return (
1145
+ identityText.length === 0 &&
1146
+ String(componentBody?.layer || '').trim().length === 0 &&
1147
+ Boolean(componentBody?.staticGeometry) &&
1148
+ String(componentBody.staticGeometry?.status || '').toLowerCase() ===
1149
+ 'complete'
1150
+ )
1151
+ }
1152
+
757
1153
  /**
758
1154
  * Returns true when the body/component anchors are close enough to be
759
1155
  * considered an explicit placement match.
@@ -923,6 +1319,154 @@ export class PcbScene3dBuilder {
923
1319
  )
924
1320
  }
925
1321
 
1322
+ /**
1323
+ * Checks whether a package-like shape body is an authored timing-stack
1324
+ * sub-body that should stay represented by its carrier static geometry.
1325
+ * @param {object} componentBody Component-body record.
1326
+ * @param {object | null} matchedComponent Matched component.
1327
+ * @param {{ designator?: string, x?: number, y?: number, pattern?: string, source?: string, description?: string, provenance?: object, parameters?: object }[]} components PCB components.
1328
+ * @returns {boolean}
1329
+ */
1330
+ static #isPositiveTimingStackPackageBody(
1331
+ componentBody,
1332
+ matchedComponent,
1333
+ components
1334
+ ) {
1335
+ const standoff = Number(componentBody?.standoffHeightMil)
1336
+ const hasTimingOwner = matchedComponent
1337
+ ? PcbScene3dBuilder.#isTimingStackComponent(matchedComponent)
1338
+ : PcbScene3dBuilder.#hasNearbyTimingStackOwner(
1339
+ componentBody,
1340
+ components
1341
+ )
1342
+ const hasLocalComponentOwner =
1343
+ PcbScene3dBuilder.#hasNearbyNonTimingPackageOwner(
1344
+ componentBody,
1345
+ components
1346
+ )
1347
+
1348
+ return (
1349
+ PcbScene3dBuilder.#isShapeBasedComponentBody(componentBody) &&
1350
+ componentBody?.embedded === true &&
1351
+ Number.isFinite(standoff) &&
1352
+ standoff > 0 &&
1353
+ PcbScene3dBuilder.#isComponentPackageBody(componentBody) &&
1354
+ !PcbScene3dBuilder.#isTimingStackBodyIdentity(componentBody) &&
1355
+ !PcbScene3dBuilder.#isAuthoredBodyIdentity(componentBody) &&
1356
+ hasTimingOwner &&
1357
+ !hasLocalComponentOwner
1358
+ )
1359
+ }
1360
+
1361
+ /**
1362
+ * Checks whether a shape-based sub-body is the actual timing package
1363
+ * rather than a passive support part inside the timing stack.
1364
+ * @param {{ name?: string, identifier?: string }} componentBody Component-body record.
1365
+ * @returns {boolean}
1366
+ */
1367
+ static #isTimingStackBodyIdentity(componentBody) {
1368
+ return PcbScene3dBuilder.#TIMING_STACK_COMPONENT_PATTERN.test(
1369
+ [componentBody?.identifier, componentBody?.name]
1370
+ .map((value) => String(value || ''))
1371
+ .join(' ')
1372
+ )
1373
+ }
1374
+
1375
+ /**
1376
+ * Checks whether one package-like body is the authored timing package for
1377
+ * a timing-stack component.
1378
+ * @param {{ name?: string, identifier?: string }} componentBody Component-body record.
1379
+ * @param {object} component PCB component.
1380
+ * @returns {boolean}
1381
+ */
1382
+ static #isTimingStackBodyComponentPair(componentBody, component) {
1383
+ return (
1384
+ PcbScene3dBuilder.#isTimingStackBodyIdentity(componentBody) &&
1385
+ PcbScene3dBuilder.#isTimingStackComponent(component)
1386
+ )
1387
+ }
1388
+
1389
+ /**
1390
+ * Checks whether a positive package body has its own nearby non-timing
1391
+ * component and should not be treated as an unowned timing-stack detail.
1392
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component-body record.
1393
+ * @param {object[]} components PCB components.
1394
+ * @returns {boolean}
1395
+ */
1396
+ static #hasNearbyNonTimingPackageOwner(componentBody, components) {
1397
+ return (Array.isArray(components) ? components : []).some(
1398
+ (component) =>
1399
+ !PcbScene3dBuilder.#isTimingStackComponent(component) &&
1400
+ PcbScene3dBuilder.#distanceBetweenBodyAndComponent(
1401
+ componentBody,
1402
+ component
1403
+ ) <= PcbScene3dBuilder.#EXACT_BODY_MISMATCH_TOLERANCE_MIL &&
1404
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
1405
+ componentBody,
1406
+ component
1407
+ ) > 0
1408
+ )
1409
+ }
1410
+
1411
+ /**
1412
+ * Checks whether an unmatched body sits inside a timing-package stack.
1413
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Component-body record.
1414
+ * @param {object[]} components PCB components.
1415
+ * @returns {boolean}
1416
+ */
1417
+ static #hasNearbyTimingStackOwner(componentBody, components) {
1418
+ return (Array.isArray(components) ? components : []).some(
1419
+ (component) =>
1420
+ PcbScene3dBuilder.#isTimingStackComponent(component) &&
1421
+ PcbScene3dBuilder.#distanceBetweenBodyAndComponent(
1422
+ componentBody,
1423
+ component
1424
+ ) <= PcbScene3dBuilder.#TIMING_STACK_BODY_RADIUS_MIL
1425
+ )
1426
+ }
1427
+
1428
+ /**
1429
+ * Checks whether one component is a timing-package stack owner.
1430
+ * @param {object} component PCB component.
1431
+ * @returns {boolean}
1432
+ */
1433
+ static #isTimingStackComponent(component) {
1434
+ const designator = String(component?.designator || '').trim()
1435
+ if (
1436
+ PcbScene3dBuilder.#TIMING_STACK_DESIGNATOR_PATTERN.test(designator)
1437
+ ) {
1438
+ return true
1439
+ }
1440
+
1441
+ return PcbScene3dBuilder.#TIMING_STACK_COMPONENT_PATTERN.test(
1442
+ [
1443
+ component?.pattern,
1444
+ component?.source,
1445
+ component?.description,
1446
+ component?.provenance?.footprintDescription,
1447
+ ...Object.values(component?.parameters || {})
1448
+ ]
1449
+ .map((value) => String(value || ''))
1450
+ .join(' ')
1451
+ )
1452
+ }
1453
+
1454
+ /**
1455
+ * Checks whether a body already has complete static geometry and should
1456
+ * not also be emitted as an external model placement.
1457
+ * @param {{ embedded?: boolean, staticGeometry?: { status?: string } }} componentBody Component-body record.
1458
+ * @returns {boolean}
1459
+ */
1460
+ static #shouldRenderStaticGeometryOnly(componentBody) {
1461
+ return (
1462
+ PcbScene3dBuilder.#isShapeBasedComponentBody(componentBody) &&
1463
+ !componentBody?.embedded &&
1464
+ String(
1465
+ componentBody?.staticGeometry?.status || ''
1466
+ ).toLowerCase() === 'complete'
1467
+ )
1468
+ }
1469
+
926
1470
  /**
927
1471
  * Checks whether a precise body/component pair is a package-family mismatch.
928
1472
  * @param {{ name?: string, identifier?: string }} componentBody Component-body record.
@@ -939,6 +1483,10 @@ export class PcbScene3dBuilder {
939
1483
  Number(distanceMil || 0) <=
940
1484
  PcbScene3dBuilder.#EXACT_BODY_MISMATCH_TOLERANCE_MIL &&
941
1485
  PcbScene3dBuilder.#isComponentPackageBody(componentBody) &&
1486
+ !PcbScene3dBuilder.#isTimingStackBodyComponentPair(
1487
+ componentBody,
1488
+ component
1489
+ ) &&
942
1490
  !PcbScene3dBuilder.#isAuthoredBodyIdentity(componentBody) &&
943
1491
  PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
944
1492
  componentBody,
@@ -1717,11 +2265,16 @@ export class PcbScene3dBuilder {
1717
2265
 
1718
2266
  /**
1719
2267
  * Resolves the owned or nearby pad-span box around one component.
1720
- * @param {{ x: number, y: number, componentIndex?: number, layer?: string }} component
2268
+ * @param {{ x: number, y: number, componentIndex?: number, layer?: string, rotation?: number }} component
1721
2269
  * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads
2270
+ * @param {number} [rotationDeg] Body-local rotation used for span measurement.
1722
2271
  * @returns {{ width: number, depth: number }}
1723
2272
  */
1724
- static #resolvePadSpan(component, pads) {
2273
+ static #resolvePadSpan(
2274
+ component,
2275
+ pads,
2276
+ rotationDeg = Number(component?.rotation || 0)
2277
+ ) {
1725
2278
  const componentPads = PcbScene3dBuilder.#componentPads(component, pads)
1726
2279
  const nearbyPads = pads.filter((pad) =>
1727
2280
  PcbScene3dBuilder.#isPadNearComponent(component, pad)
@@ -1735,13 +2288,27 @@ export class PcbScene3dBuilder {
1735
2288
  const mountSide = PcbScene3dBuilder.#resolveMountSide(component)
1736
2289
  return (
1737
2290
  PcbScene3dPadLocalSpanResolver.resolve(
1738
- component,
2291
+ { ...component, rotation: rotationDeg },
1739
2292
  spanPads,
1740
2293
  mountSide
1741
2294
  ) || { width: 0, depth: 0 }
1742
2295
  )
1743
2296
  }
1744
2297
 
2298
+ /**
2299
+ * Resolves the visible procedural component rotation.
2300
+ * @param {{ componentIndex?: number, rotation?: number }} component PCB component.
2301
+ * @param {object[]} pads PCB pads.
2302
+ * @param {string} mountSide Component mount side.
2303
+ * @returns {number}
2304
+ */
2305
+ static #resolveComponentRotation(component, pads, mountSide) {
2306
+ return (
2307
+ PcbScene3dPadYawResolver.resolve(component, pads, mountSide) ??
2308
+ Number(component?.rotation || 0)
2309
+ )
2310
+ }
2311
+
1745
2312
  /**
1746
2313
  * Resolves pads explicitly owned by one component, preferring pads on the
1747
2314
  * mounted surface when paste-mask side metadata is available.