altium-toolkit 1.1.33 → 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.
@@ -15,8 +15,10 @@ import { PcbScene3dPadLocalSpanResolver } from './PcbScene3dPadLocalSpanResolver
15
15
  import { PcbScene3dPackages } from './PcbScene3dPackages.mjs'
16
16
  import { PcbScene3dPlacementSideResolver } from './PcbScene3dPlacementSideResolver.mjs'
17
17
  import { PcbScene3dStaticBodyPlacementBuilder } from './PcbScene3dStaticBodyPlacementBuilder.mjs'
18
+ import { PcbScene3dPadYawResolver } from './PcbScene3dPadYawResolver.mjs'
18
19
  import { PcbScene3dTextBoxLayoutResolver } from './PcbScene3dTextBoxLayoutResolver.mjs'
19
20
  import { PcbFootprintPadAxisNormalizer } from './PcbFootprintPadAxisNormalizer.mjs'
21
+ import { PcbScene3dCopperRegionDetailBuilder } from './PcbScene3dCopperRegionDetailBuilder.mjs'
20
22
 
21
23
  /**
22
24
  * Builds deterministic 3D scene data from the normalized PCB model.
@@ -33,13 +35,26 @@ export class PcbScene3dBuilder {
33
35
  static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
34
36
  static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
35
37
  static #OVERSIZED_GENERIC_FALLBACK_MAX_MIL = 800
38
+ static #TIMING_STACK_BODY_RADIUS_MIL = 220
36
39
  static #TRUETYPE_TEXT_WIDTH_RATIO = 0.55
37
40
  static #LOW_CONFIDENCE_GENERIC_FOOTPRINT_PATTERN =
38
41
  /(?:^|[^a-z0-9])(?:edge|finger|fingers|contact|contacts|mech|mechanical|jumper|jump)(?:$|[^a-z0-9])/i
39
42
  static #AUTHORED_BODY_IDENTITY_PATTERN =
40
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
41
53
  static #COMPONENT_PACKAGE_BODY_PATTERN =
42
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
43
58
 
44
59
  /**
45
60
  * Builds a scene description for host 3D renderers.
@@ -64,6 +79,7 @@ export class PcbScene3dBuilder {
64
79
  const tracks = Array.isArray(pcb.tracks) ? pcb.tracks : []
65
80
  const arcs = Array.isArray(pcb.arcs) ? pcb.arcs : []
66
81
  const fills = Array.isArray(pcb.fills) ? pcb.fills : []
82
+ const regionFills = PcbScene3dCopperRegionDetailBuilder.build(pcb)
67
83
  const texts = Array.isArray(pcb.texts) ? pcb.texts : []
68
84
  const vias = Array.isArray(pcb.vias) ? pcb.vias : []
69
85
  const silkscreenRegions =
@@ -91,6 +107,7 @@ export class PcbScene3dBuilder {
91
107
  : []
92
108
  }
93
109
  const componentBodyModels = componentBodies.map((componentBody) =>
110
+ PcbScene3dBuilder.#shouldRenderStaticGeometryOnly(componentBody) ||
94
111
  PcbScene3dBuilder.#shouldSuppressLayerlessBodyPlaceholder(
95
112
  componentBody,
96
113
  componentBodies,
@@ -184,7 +201,7 @@ export class PcbScene3dBuilder {
184
201
  pads,
185
202
  tracks,
186
203
  arcs,
187
- fills,
204
+ fills: [...fills, ...regionFills],
188
205
  vias,
189
206
  polygons: Array.isArray(pcb.polygons) ? pcb.polygons : [],
190
207
  silkscreen: {
@@ -220,7 +237,7 @@ export class PcbScene3dBuilder {
220
237
  * @param {{ centerX: number, centerY: number }} board
221
238
  * @param {number} thicknessMil
222
239
  * @param {{ resolveComponentModel: (component: any) => { name: string, relativePath: string, format: string } | null } | null} modelRegistry
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}
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}
224
241
  */
225
242
  static #buildComponent(
226
243
  component,
@@ -230,20 +247,26 @@ export class PcbScene3dBuilder {
230
247
  modelRegistry
231
248
  ) {
232
249
  const mountSide = PcbScene3dBuilder.#resolveMountSide(component)
233
- 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
+ )
234
260
  const body = PcbScene3dPackages.resolve(component, padSpan)
235
261
  const externalModel = modelRegistry
236
262
  ? modelRegistry.resolveComponentModel(component)
237
263
  : null
238
- if (
264
+ const suppressFallbackBody =
239
265
  PcbScene3dBuilder.#shouldSuppressProceduralComponent(
240
266
  component,
241
267
  body,
242
268
  externalModel
243
269
  )
244
- ) {
245
- return null
246
- }
247
270
 
248
271
  const halfBoardThickness = thicknessMil / 2
249
272
  const halfBodyHeight = body.sizeMil.height / 2
@@ -253,9 +276,12 @@ export class PcbScene3dBuilder {
253
276
  : halfBoardThickness + halfBodyHeight
254
277
 
255
278
  return {
279
+ componentIndex: Number.isFinite(Number(component.componentIndex))
280
+ ? Number(component.componentIndex)
281
+ : null,
256
282
  designator: component.designator,
257
283
  mountSide,
258
- rotationDeg: Number(component.rotation || 0),
284
+ rotationDeg,
259
285
  positionMil: {
260
286
  x: Number(component.x || 0) - Number(board.centerX || 0),
261
287
  y: Number(component.y || 0) - Number(board.centerY || 0),
@@ -276,7 +302,8 @@ export class PcbScene3dBuilder {
276
302
  ? { ...component.parameters }
277
303
  : {},
278
304
  body,
279
- externalModel
305
+ externalModel,
306
+ ...(suppressFallbackBody ? { renderFallbackBody: false } : {})
280
307
  }
281
308
  }
282
309
 
@@ -291,11 +318,44 @@ export class PcbScene3dBuilder {
291
318
  return (
292
319
  !externalModel &&
293
320
  body?.family === 'generic' &&
294
- PcbScene3dBuilder.#isOversizedGenericFallback(body) &&
295
- PcbScene3dBuilder.#isLowConfidenceGenericFootprint(component)
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)
296
338
  )
297
339
  }
298
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
+
299
359
  /**
300
360
  * Checks whether one generic fallback body spans too much of the board.
301
361
  * @param {{ sizeMil?: { width?: number, depth?: number } }} body Procedural body.
@@ -339,7 +399,7 @@ export class PcbScene3dBuilder {
339
399
  /**
340
400
  * Builds one explicit external-model placement from normalized component
341
401
  * body metadata.
342
- * @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
343
403
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null } | null} matchedComponent
344
404
  * @param {{ origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } | null} resolvedModel
345
405
  * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, source?: string, modelPath?: string }[]} components
@@ -357,10 +417,24 @@ export class PcbScene3dBuilder {
357
417
  board,
358
418
  thicknessMil
359
419
  ) {
420
+ if (PcbScene3dBuilder.#shouldRenderStaticGeometryOnly(componentBody)) {
421
+ return null
422
+ }
423
+
360
424
  if (!resolvedModel) {
361
425
  return null
362
426
  }
363
427
 
428
+ if (
429
+ PcbScene3dBuilder.#isPositiveTimingStackPackageBody(
430
+ componentBody,
431
+ matchedComponent,
432
+ components
433
+ )
434
+ ) {
435
+ return null
436
+ }
437
+
364
438
  if (
365
439
  !matchedComponent &&
366
440
  PcbScene3dBuilder.#shouldDropUnmatchedPackageBody(
@@ -429,10 +503,24 @@ export class PcbScene3dBuilder {
429
503
  pads,
430
504
  resolvedModel
431
505
  ),
506
+ ...PcbScene3dBuilder.#componentBodyDisplayMetadata(componentBody),
432
507
  externalModel: resolvedModel
433
508
  }
434
509
  }
435
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
+
436
524
  /**
437
525
  * Resolves the vertical offset that should remain after the viewer seats
438
526
  * raw model bounds on the board face.
@@ -478,8 +566,47 @@ export class PcbScene3dBuilder {
478
566
  matchedComponent
479
567
  ) {
480
568
  return (
481
- !matchedComponent &&
482
- PcbScene3dBuilder.#isShapeBasedComponentBody(componentBody)
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
483
610
  )
484
611
  }
485
612
 
@@ -643,7 +770,8 @@ export class PcbScene3dBuilder {
643
770
 
644
771
  componentBodies.forEach((componentBody, bodyIndex) => {
645
772
  if (
646
- !PcbScene3dBuilder.#isResolvableComponentBody(
773
+ !PcbScene3dBuilder.#isMatchableComponentBody(
774
+ componentBody,
647
775
  resolvedBodyModels,
648
776
  bodyIndex
649
777
  )
@@ -730,7 +858,8 @@ export class PcbScene3dBuilder {
730
858
  const groupedBodyIndexes = new Map()
731
859
  componentBodies.forEach((componentBody, bodyIndex) => {
732
860
  if (
733
- !PcbScene3dBuilder.#isResolvableComponentBody(
861
+ !PcbScene3dBuilder.#isMatchableComponentBody(
862
+ componentBody,
734
863
  resolvedBodyModels,
735
864
  bodyIndex
736
865
  )
@@ -799,9 +928,115 @@ export class PcbScene3dBuilder {
799
928
  })
800
929
  })
801
930
 
931
+ PcbScene3dBuilder.#assignStaticShieldFrameBodyMatches(
932
+ matches,
933
+ componentBodies,
934
+ components
935
+ )
936
+
802
937
  return matches
803
938
  }
804
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
+
805
1040
  /**
806
1041
  * Builds reusable identity statistics for body/component matching.
807
1042
  * @param {{ modelId?: string, name?: string, identifier?: string }[]} componentBodies
@@ -820,7 +1055,8 @@ export class PcbScene3dBuilder {
820
1055
 
821
1056
  componentBodies.forEach((componentBody, bodyIndex) => {
822
1057
  if (
823
- !PcbScene3dBuilder.#isResolvableComponentBody(
1058
+ !PcbScene3dBuilder.#isMatchableComponentBody(
1059
+ componentBody,
824
1060
  resolvedBodyModels,
825
1061
  bodyIndex
826
1062
  )
@@ -857,6 +1093,29 @@ export class PcbScene3dBuilder {
857
1093
  return { bodyGroupCounts, candidateComponentCounts }
858
1094
  }
859
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
+
860
1119
  /**
861
1120
  * Returns true when one body row can produce a renderable external model.
862
1121
  * @param {unknown[]} resolvedBodyModels Resolved body-model entries.
@@ -871,6 +1130,26 @@ export class PcbScene3dBuilder {
871
1130
  )
872
1131
  }
873
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
+
874
1153
  /**
875
1154
  * Returns true when the body/component anchors are close enough to be
876
1155
  * considered an explicit placement match.
@@ -1040,6 +1319,154 @@ export class PcbScene3dBuilder {
1040
1319
  )
1041
1320
  }
1042
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
+
1043
1470
  /**
1044
1471
  * Checks whether a precise body/component pair is a package-family mismatch.
1045
1472
  * @param {{ name?: string, identifier?: string }} componentBody Component-body record.
@@ -1056,6 +1483,10 @@ export class PcbScene3dBuilder {
1056
1483
  Number(distanceMil || 0) <=
1057
1484
  PcbScene3dBuilder.#EXACT_BODY_MISMATCH_TOLERANCE_MIL &&
1058
1485
  PcbScene3dBuilder.#isComponentPackageBody(componentBody) &&
1486
+ !PcbScene3dBuilder.#isTimingStackBodyComponentPair(
1487
+ componentBody,
1488
+ component
1489
+ ) &&
1059
1490
  !PcbScene3dBuilder.#isAuthoredBodyIdentity(componentBody) &&
1060
1491
  PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
1061
1492
  componentBody,
@@ -1834,11 +2265,16 @@ export class PcbScene3dBuilder {
1834
2265
 
1835
2266
  /**
1836
2267
  * Resolves the owned or nearby pad-span box around one component.
1837
- * @param {{ x: number, y: number, componentIndex?: number, layer?: string }} component
2268
+ * @param {{ x: number, y: number, componentIndex?: number, layer?: string, rotation?: number }} component
1838
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.
1839
2271
  * @returns {{ width: number, depth: number }}
1840
2272
  */
1841
- static #resolvePadSpan(component, pads) {
2273
+ static #resolvePadSpan(
2274
+ component,
2275
+ pads,
2276
+ rotationDeg = Number(component?.rotation || 0)
2277
+ ) {
1842
2278
  const componentPads = PcbScene3dBuilder.#componentPads(component, pads)
1843
2279
  const nearbyPads = pads.filter((pad) =>
1844
2280
  PcbScene3dBuilder.#isPadNearComponent(component, pad)
@@ -1852,13 +2288,27 @@ export class PcbScene3dBuilder {
1852
2288
  const mountSide = PcbScene3dBuilder.#resolveMountSide(component)
1853
2289
  return (
1854
2290
  PcbScene3dPadLocalSpanResolver.resolve(
1855
- component,
2291
+ { ...component, rotation: rotationDeg },
1856
2292
  spanPads,
1857
2293
  mountSide
1858
2294
  ) || { width: 0, depth: 0 }
1859
2295
  )
1860
2296
  }
1861
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
+
1862
2312
  /**
1863
2313
  * Resolves pads explicitly owned by one component, preferring pads on the
1864
2314
  * mounted surface when paste-mask side metadata is available.