altium-toolkit 1.1.35 → 1.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/AGENTS.md +17 -2
  2. package/package.json +1 -1
  3. package/src/core/altium/PrintableTextDecoder.mjs +20 -1
  4. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +288 -7
  5. package/src/core/altium/SchematicTextParser.mjs +18 -253
  6. package/src/core/altium/SchematicTitleBlockParser.mjs +410 -0
  7. package/src/core/altium/SelectedPartAltiumExportAdapter.mjs +215 -0
  8. package/src/parser.mjs +1 -0
  9. package/src/ui/AltiumScene3dAuthoredBodyAnchorAdapter.mjs +245 -12
  10. package/src/ui/AltiumScene3dAuthoredConnectorYawPolicy.mjs +176 -0
  11. package/src/ui/AltiumScene3dBottomSourceHalfTurnPolicy.mjs +93 -0
  12. package/src/ui/AltiumScene3dDisplayModuleYawPolicy.mjs +284 -0
  13. package/src/ui/AltiumScene3dExternalPlacementAdapter.mjs +521 -23
  14. package/src/ui/AltiumScene3dPlacementRotationPolicy.mjs +766 -19
  15. package/src/ui/AltiumScene3dQfnFootprintDetector.mjs +307 -0
  16. package/src/ui/AltiumScene3dRepeatedFullFootprintBodyCollapse.mjs +518 -0
  17. package/src/ui/AltiumScene3dRepeatedModelOwnerRepair.mjs +121 -17
  18. package/src/ui/AltiumScene3dShapeStackOwnerAdapter.mjs +286 -135
  19. package/src/ui/AltiumScene3dShapeStackOwnerConflictPolicy.mjs +299 -0
  20. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +22 -0
  21. package/src/ui/PcbScene3dBuilder.mjs +613 -117
  22. package/src/ui/PcbScene3dModelRegistry.mjs +222 -10
  23. package/src/ui/PcbScene3dPackages.mjs +16 -1
  24. package/src/ui/PcbScene3dPlacementSideResolver.mjs +262 -22
  25. package/src/ui/PcbScene3dStaticBodyOwnerPromotion.mjs +984 -0
  26. package/src/ui/PcbScene3dStaticBodyPadOwnerPromotion.mjs +378 -0
  27. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +169 -23
  28. package/src/ui/PcbScene3dStaticBodyPrototypeRecovery.mjs +445 -0
  29. package/src/ui/PcbScene3dStaticBodyRecovery.mjs +7 -1
  30. package/src/ui/PcbScene3dStaticBodySelectionKeyBuilder.mjs +199 -2
  31. package/src/ui/PcbScene3dStaticBodySymmetricOwnerPromotion.mjs +439 -0
  32. package/src/ui/PcbScene3dStaticBodySymmetryRecovery.mjs +23 -2
  33. package/src/ui/SchematicColorResolver.mjs +44 -0
  34. package/src/ui/SchematicLineColorResolver.mjs +1 -1
  35. package/src/ui/SchematicShapeRenderer.mjs +13 -13
@@ -19,6 +19,7 @@ import { PcbScene3dPadYawResolver } from './PcbScene3dPadYawResolver.mjs'
19
19
  import { PcbScene3dTextBoxLayoutResolver } from './PcbScene3dTextBoxLayoutResolver.mjs'
20
20
  import { PcbFootprintPadAxisNormalizer } from './PcbFootprintPadAxisNormalizer.mjs'
21
21
  import { PcbScene3dCopperRegionDetailBuilder } from './PcbScene3dCopperRegionDetailBuilder.mjs'
22
+ import { AltiumScene3dBottomSourceHalfTurnPolicy } from './AltiumScene3dBottomSourceHalfTurnPolicy.mjs'
22
23
 
23
24
  /**
24
25
  * Builds deterministic 3D scene data from the normalized PCB model.
@@ -45,16 +46,23 @@ export class PcbScene3dBuilder {
45
46
  /(?:^|[^a-z0-9])(?:emi|rf|rfi|shield|cover|can)(?:$|[^a-z0-9])/i
46
47
  static #MECHANICAL_SHIELD_FALLBACK_PATTERN =
47
48
  /(?:^|[^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
49
+ static #MECHANICAL_SHIELD_FRAME_OWNER_TOKENS = [
50
+ 'emi',
51
+ 'rfi',
52
+ 'rf',
53
+ 'shield',
54
+ 'can'
55
+ ]
50
56
  static #MECHANICAL_SHIELD_FRAME_BODY_PATTERN =
51
57
  /(?:^|[^a-z0-9])(?:frame[0-9]*|leg|rail|side|wall)(?:$|[^a-z0-9])/i
52
58
  static #MECHANICAL_SHIELD_FRAME_OWNER_RADIUS_MIL = 750
53
59
  static #COMPONENT_PACKAGE_BODY_PATTERN =
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
60
+ /(?:^|[^a-z0-9])(?:[a-z0-9]*dfn[a-z0-9]*|[a-z0-9]*qfn[a-z0-9]*|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
61
  static #TIMING_STACK_COMPONENT_PATTERN =
56
62
  /(?:^|[^a-z0-9])(?:clock|crystal|osc|oscillator|resonator|tcxo|txco|xtal)(?:$|[^a-z0-9])/i
57
63
  static #TIMING_STACK_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
64
+ static #TIMING_STACK_HEIGHT_TOLERANCE_MIL = 0.1
65
+ static #TIMING_STACK_CARRIER_BOUNDS_TOLERANCE_MIL = 5
58
66
 
59
67
  /**
60
68
  * Builds a scene description for host 3D renderers.
@@ -156,44 +164,55 @@ export class PcbScene3dBuilder {
156
164
  appearance3d
157
165
  )
158
166
 
167
+ const sceneComponents = components
168
+ .map((component) =>
169
+ PcbScene3dBuilder.#buildComponent(
170
+ component,
171
+ pads,
172
+ board,
173
+ thicknessMil,
174
+ modelRegistry
175
+ )
176
+ )
177
+ .filter(Boolean)
178
+ const externalPlacements = componentBodies
179
+ .map((componentBody, index) =>
180
+ PcbScene3dBuilder.#buildExternalPlacement(
181
+ componentBody,
182
+ bodyMatches[index],
183
+ componentBodyModels[index],
184
+ components,
185
+ pads,
186
+ board,
187
+ thicknessMil,
188
+ componentBodies
189
+ )
190
+ )
191
+ .filter(Boolean)
192
+ const staticBodyPlacements = PcbScene3dStaticBodyPlacementBuilder.build(
193
+ componentBodies,
194
+ bodyMatches,
195
+ components,
196
+ pads,
197
+ board,
198
+ thicknessMil
199
+ )
200
+
159
201
  const sceneDescription = {
160
202
  sourceFormat: 'altium',
161
203
  board,
162
204
  boardAssemblyModel:
163
205
  modelRegistry?.resolveBoardAssemblyModel?.(documentModel) ||
164
206
  null,
165
- components: components
166
- .map((component) =>
167
- PcbScene3dBuilder.#buildComponent(
168
- component,
169
- pads,
170
- board,
171
- thicknessMil,
172
- modelRegistry
173
- )
174
- )
175
- .filter(Boolean),
176
- externalPlacements: componentBodies
177
- .map((componentBody, index) =>
178
- PcbScene3dBuilder.#buildExternalPlacement(
179
- componentBody,
180
- bodyMatches[index],
181
- componentBodyModels[index],
182
- components,
183
- pads,
184
- board,
185
- thicknessMil
186
- )
187
- )
188
- .filter(Boolean),
189
- staticBodyPlacements: PcbScene3dStaticBodyPlacementBuilder.build(
190
- componentBodies,
191
- bodyMatches,
192
- components,
193
- pads,
194
- board,
195
- thicknessMil
196
- ),
207
+ components:
208
+ PcbScene3dBuilder.#suppressExternallyCoveredFallbackBodies(
209
+ sceneComponents,
210
+ components,
211
+ externalPlacements,
212
+ pads
213
+ ),
214
+ externalPlacements,
215
+ staticBodyPlacements,
197
216
  detail: {
198
217
  embeddedFonts: Array.isArray(pcb.embeddedFonts)
199
218
  ? pcb.embeddedFonts
@@ -326,6 +345,106 @@ export class PcbScene3dBuilder {
326
345
  )
327
346
  }
328
347
 
348
+ /**
349
+ * Suppresses procedural fallbacks when explicit body placements already
350
+ * cover every drilled pad owned by the same component.
351
+ * @param {object[]} sceneComponents Scene component rows.
352
+ * @param {object[]} sourceComponents Source PCB component rows.
353
+ * @param {object[]} externalPlacements Built external placements.
354
+ * @param {object[]} pads PCB pad rows.
355
+ * @returns {object[]}
356
+ */
357
+ static #suppressExternallyCoveredFallbackBodies(
358
+ sceneComponents,
359
+ sourceComponents,
360
+ externalPlacements,
361
+ pads
362
+ ) {
363
+ const sourceByDesignator = new Map(
364
+ (Array.isArray(sourceComponents) ? sourceComponents : []).map(
365
+ (component) => [String(component?.designator || ''), component]
366
+ )
367
+ )
368
+
369
+ return (Array.isArray(sceneComponents) ? sceneComponents : []).map(
370
+ (component) =>
371
+ PcbScene3dBuilder.#hasExternalPlacementPadCoverage(
372
+ sourceByDesignator.get(
373
+ String(component?.designator || '')
374
+ ) || component,
375
+ component,
376
+ externalPlacements,
377
+ pads
378
+ )
379
+ ? { ...component, renderFallbackBody: false }
380
+ : component
381
+ )
382
+ }
383
+
384
+ /**
385
+ * Checks whether explicit placements occupy all drilled pads for a component.
386
+ * @param {object} sourceComponent Source PCB component.
387
+ * @param {object} sceneComponent Scene component.
388
+ * @param {object[]} externalPlacements Built external placements.
389
+ * @param {object[]} pads PCB pad rows.
390
+ * @returns {boolean}
391
+ */
392
+ static #hasExternalPlacementPadCoverage(
393
+ sourceComponent,
394
+ sceneComponent,
395
+ externalPlacements,
396
+ pads
397
+ ) {
398
+ if (sceneComponent?.renderFallbackBody === false) {
399
+ return false
400
+ }
401
+
402
+ const designator = String(sceneComponent?.designator || '')
403
+ const componentPads = PcbScene3dBuilder.#componentPads(
404
+ sourceComponent,
405
+ pads
406
+ ).filter((pad) => PcbScene3dBuilder.#hasDrilledPadOpening(pad))
407
+ if (componentPads.length < 2) {
408
+ return false
409
+ }
410
+
411
+ const placements = (
412
+ Array.isArray(externalPlacements) ? externalPlacements : []
413
+ ).filter(
414
+ (placement) => String(placement?.designator || '') === designator
415
+ )
416
+ if (placements.length < componentPads.length) {
417
+ return false
418
+ }
419
+
420
+ return componentPads.every((pad) =>
421
+ placements.some((placement) =>
422
+ PcbScene3dBuilder.#placementCoversPad(placement, pad)
423
+ )
424
+ )
425
+ }
426
+
427
+ /**
428
+ * Checks whether one external placement anchor covers one drilled pad.
429
+ * @param {object} placement External model placement.
430
+ * @param {object} pad PCB pad row.
431
+ * @returns {boolean}
432
+ */
433
+ static #placementCoversPad(placement, pad) {
434
+ const point = placement?.bodyPositionMil
435
+ if (
436
+ !Number.isFinite(Number(point?.x)) ||
437
+ !Number.isFinite(Number(point?.y))
438
+ ) {
439
+ return false
440
+ }
441
+
442
+ return (
443
+ PcbScene3dBuilder.#distanceToPadAnchor(point, pad) <=
444
+ PcbScene3dBuilder.#EXACT_BODY_MISMATCH_TOLERANCE_MIL
445
+ )
446
+ }
447
+
329
448
  /**
330
449
  * Checks whether a generic component row describes authored shield
331
450
  * hardware that should not become a filled fallback box.
@@ -406,6 +525,7 @@ export class PcbScene3dBuilder {
406
525
  * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads
407
526
  * @param {{ centerX: number, centerY: number }} board
408
527
  * @param {number} thicknessMil
528
+ * @param {object[]} componentBodies All source component bodies.
409
529
  * @returns {{ designator: string, mountSide: string, rotationDeg: number, positionMil: { x: number, y: number, z: number }, bodyPositionMil: { x: number, y: number }, bodyRotationDeg: number, modelTransform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number }, externalModel: { origin: string, name: string, format: string, payloadText?: string, sourceStream?: string, relativePath?: string } } | null}
410
530
  */
411
531
  static #buildExternalPlacement(
@@ -415,7 +535,8 @@ export class PcbScene3dBuilder {
415
535
  components,
416
536
  pads,
417
537
  board,
418
- thicknessMil
538
+ thicknessMil,
539
+ componentBodies
419
540
  ) {
420
541
  if (PcbScene3dBuilder.#shouldRenderStaticGeometryOnly(componentBody)) {
421
542
  return null
@@ -425,18 +546,31 @@ export class PcbScene3dBuilder {
425
546
  return null
426
547
  }
427
548
 
549
+ const sourcePosition =
550
+ PcbScene3dBuilder.#resolveExternalPlacementSourcePosition(
551
+ componentBody
552
+ )
553
+ const resolvedMatchedComponent =
554
+ matchedComponent ||
555
+ PcbScene3dBuilder.#resolveComponentFromOwnedDrilledPad(
556
+ sourcePosition,
557
+ components,
558
+ pads
559
+ )
560
+
428
561
  if (
429
562
  PcbScene3dBuilder.#isPositiveTimingStackPackageBody(
430
563
  componentBody,
431
- matchedComponent,
432
- components
564
+ resolvedMatchedComponent,
565
+ components,
566
+ componentBodies
433
567
  )
434
568
  ) {
435
569
  return null
436
570
  }
437
571
 
438
572
  if (
439
- !matchedComponent &&
573
+ !resolvedMatchedComponent &&
440
574
  PcbScene3dBuilder.#shouldDropUnmatchedPackageBody(
441
575
  componentBody,
442
576
  components
@@ -446,7 +580,7 @@ export class PcbScene3dBuilder {
446
580
  }
447
581
 
448
582
  if (
449
- !matchedComponent &&
583
+ !resolvedMatchedComponent &&
450
584
  !PcbScene3dBuilder.#isBodyPositionNearBoard(componentBody, board)
451
585
  ) {
452
586
  return null
@@ -454,28 +588,28 @@ export class PcbScene3dBuilder {
454
588
 
455
589
  const mountSide = PcbScene3dPlacementSideResolver.resolvePlacementSide(
456
590
  componentBody,
457
- matchedComponent,
591
+ resolvedMatchedComponent,
458
592
  components,
459
593
  board
460
594
  )
461
595
  const halfBoardThickness = thicknessMil / 2
462
- const sourcePosition =
463
- PcbScene3dBuilder.#resolveExternalPlacementSourcePosition(
464
- componentBody
465
- )
466
- const modelRotation =
467
- PcbScene3dBuilder.#resolveExternalModelRotation(componentBody)
596
+ const modelRotation = PcbScene3dBuilder.#resolveExternalModelRotation(
597
+ componentBody,
598
+ resolvedMatchedComponent,
599
+ pads,
600
+ mountSide
601
+ )
468
602
 
469
603
  return {
470
604
  designator:
471
- matchedComponent?.designator ||
605
+ resolvedMatchedComponent?.designator ||
472
606
  String(
473
607
  componentBody.identifier || componentBody.name || '3D model'
474
608
  ),
475
609
  mountSide,
476
610
  rotationDeg: PcbScene3dBuilder.#resolveExternalPlacementRotation(
477
611
  componentBody,
478
- matchedComponent
612
+ resolvedMatchedComponent
479
613
  ),
480
614
  positionMil: {
481
615
  x: Number(sourcePosition.x || 0) - Number(board.centerX || 0),
@@ -494,12 +628,13 @@ export class PcbScene3dBuilder {
494
628
  rotationDeg: modelRotation,
495
629
  dzMil: PcbScene3dBuilder.#resolveComponentBodyVerticalOffset(
496
630
  componentBody,
497
- matchedComponent
631
+ resolvedMatchedComponent,
632
+ mountSide
498
633
  )
499
634
  },
500
635
  projection: PcbScene3dBuilder.#resolveProjectionDiagnostics(
501
636
  componentBody,
502
- matchedComponent,
637
+ resolvedMatchedComponent,
503
638
  pads,
504
639
  resolvedModel
505
640
  ),
@@ -526,34 +661,89 @@ export class PcbScene3dBuilder {
526
661
  * raw model bounds on the board face.
527
662
  * @param {{ dzMil?: number, standoffHeightMil?: number | null }} componentBody Component-body placement metadata.
528
663
  * @param {object | null} matchedComponent Matched owner component.
664
+ * @param {'top' | 'bottom'} mountSide Resolved mount side.
529
665
  * @returns {number}
530
666
  */
531
667
  static #resolveComponentBodyVerticalOffset(
532
668
  componentBody,
533
- matchedComponent = null
669
+ matchedComponent = null,
670
+ mountSide = 'top'
534
671
  ) {
535
672
  const standoffHeightMil = Number(componentBody?.standoffHeightMil)
536
673
  if (Number.isFinite(standoffHeightMil)) {
537
- return standoffHeightMil < 0 ||
538
- PcbScene3dBuilder.#shouldPreservePositiveBodyStandoff(
674
+ if (
675
+ PcbScene3dBuilder.#shouldPreserveNegativeBodyOffset(
676
+ standoffHeightMil,
539
677
  componentBody,
540
- matchedComponent
678
+ mountSide
679
+ ) ||
680
+ (standoffHeightMil > 0 &&
681
+ PcbScene3dBuilder.#shouldPreservePositiveBodyStandoff(
682
+ componentBody,
683
+ matchedComponent
684
+ ))
685
+ ) {
686
+ return PcbScene3dBuilder.#toMountSideVerticalOffset(
687
+ standoffHeightMil,
688
+ mountSide
541
689
  )
542
- ? standoffHeightMil
543
- : 0
690
+ }
544
691
  }
545
692
 
546
693
  const dzMil = Number(componentBody?.dzMil)
547
694
  return Number.isFinite(dzMil) &&
548
- (dzMil < 0 ||
549
- PcbScene3dBuilder.#shouldPreservePositiveBodyStandoff(
550
- componentBody,
551
- matchedComponent
552
- ))
553
- ? dzMil
695
+ (PcbScene3dBuilder.#shouldPreserveNegativeBodyOffset(
696
+ dzMil,
697
+ componentBody,
698
+ mountSide
699
+ ) ||
700
+ (dzMil > 0 &&
701
+ PcbScene3dBuilder.#shouldPreservePositiveBodyStandoff(
702
+ componentBody,
703
+ matchedComponent
704
+ )))
705
+ ? PcbScene3dBuilder.#toMountSideVerticalOffset(dzMil, mountSide)
554
706
  : 0
555
707
  }
556
708
 
709
+ /**
710
+ * Converts preserved source Z offsets to the viewer mount-side convention.
711
+ * @param {number} value Source vertical offset.
712
+ * @param {'top' | 'bottom'} mountSide Resolved mount side.
713
+ * @returns {number}
714
+ */
715
+ static #toMountSideVerticalOffset(value, mountSide) {
716
+ return String(mountSide || '').toLowerCase() === 'bottom' && value < 0
717
+ ? Math.abs(value)
718
+ : value
719
+ }
720
+
721
+ /**
722
+ * Checks whether a negative source offset describes intentional top-side
723
+ * model penetration instead of a source-origin seating artifact.
724
+ * @param {number} value Source vertical offset.
725
+ * @param {{ overallHeightMil?: number | null }} componentBody Component-body placement metadata.
726
+ * @param {'top' | 'bottom'} mountSide Resolved mount side.
727
+ * @returns {boolean}
728
+ */
729
+ static #shouldPreserveNegativeBodyOffset(value, componentBody, mountSide) {
730
+ const offset = Number(value)
731
+ if (
732
+ !Number.isFinite(offset) ||
733
+ offset >= 0 ||
734
+ String(mountSide || '').toLowerCase() !== 'top'
735
+ ) {
736
+ return false
737
+ }
738
+
739
+ const overallHeight = Number(componentBody?.overallHeightMil)
740
+ return (
741
+ !Number.isFinite(overallHeight) ||
742
+ overallHeight <= 0 ||
743
+ Math.abs(offset) < overallHeight
744
+ )
745
+ }
746
+
557
747
  /**
558
748
  * Checks whether a positive shape-body standoff is part of an authored
559
749
  * stack instead of a model-origin quirk that should be seated on the board.
@@ -950,50 +1140,71 @@ export class PcbScene3dBuilder {
950
1140
  componentBodies,
951
1141
  components
952
1142
  ) {
1143
+ const shieldFrameOwners = components.filter((component) =>
1144
+ PcbScene3dBuilder.#isMechanicalShieldFrameOwner(component)
1145
+ )
1146
+
1147
+ if (!shieldFrameOwners.length) {
1148
+ return
1149
+ }
1150
+
1151
+ const shieldFrameOwnerSet = new Set(shieldFrameOwners)
1152
+
953
1153
  componentBodies.forEach((componentBody, bodyIndex) => {
954
- if (
955
- matches[bodyIndex] ||
956
- !PcbScene3dBuilder.#isStaticShieldFrameBody(componentBody)
957
- ) {
1154
+ if (!PcbScene3dBuilder.#isStaticShieldFrameBody(componentBody)) {
958
1155
  return
959
1156
  }
960
1157
 
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
1158
+ const currentOwner = matches[bodyIndex]
1159
+ if (currentOwner && shieldFrameOwnerSet.has(currentOwner)) {
1160
+ return
1161
+ }
1162
+
1163
+ let owner = null
1164
+ let ownerAffinityScore = -Infinity
1165
+ let ownerDistance = Infinity
1166
+
1167
+ shieldFrameOwners.forEach((component) => {
1168
+ if (
1169
+ !PcbScene3dBuilder.#isBodyComponentSideCompatible(
1170
+ componentBody,
1171
+ component
1172
+ )
1173
+ ) {
1174
+ return
1175
+ }
1176
+
1177
+ const distance =
1178
+ PcbScene3dBuilder.#distanceBetweenBodyAndComponent(
1179
+ componentBody,
1180
+ component
1181
+ )
1182
+
1183
+ if (
1184
+ !Number.isFinite(distance) ||
1185
+ distance >
1186
+ PcbScene3dBuilder
1187
+ .#MECHANICAL_SHIELD_FRAME_OWNER_RADIUS_MIL
1188
+ ) {
1189
+ return
1190
+ }
1191
+
1192
+ const affinityScore =
1193
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
1194
+ componentBody,
1195
+ component
1196
+ )
1197
+ const betterOwner =
1198
+ affinityScore > ownerAffinityScore ||
1199
+ (affinityScore === ownerAffinityScore &&
1200
+ distance < ownerDistance)
1201
+
1202
+ if (betterOwner) {
1203
+ owner = component
1204
+ ownerAffinityScore = affinityScore
1205
+ ownerDistance = distance
1206
+ }
1207
+ })
997
1208
 
998
1209
  if (owner) {
999
1210
  matches[bodyIndex] = owner
@@ -1007,9 +1218,30 @@ export class PcbScene3dBuilder {
1007
1218
  * @returns {boolean}
1008
1219
  */
1009
1220
  static #isMechanicalShieldFrameOwner(component) {
1010
- return PcbScene3dBuilder.#MECHANICAL_SHIELD_FRAME_OWNER_PATTERN.test(
1221
+ const identityTokens = PcbScene3dBuilder.#componentIdentityTokens(
1011
1222
  PcbScene3dBuilder.#componentIdentityText(component)
1012
1223
  )
1224
+
1225
+ return (
1226
+ identityTokens.has('frame') &&
1227
+ PcbScene3dBuilder.#MECHANICAL_SHIELD_FRAME_OWNER_TOKENS.some(
1228
+ (token) => identityTokens.has(token)
1229
+ )
1230
+ )
1231
+ }
1232
+
1233
+ /**
1234
+ * Splits component identity text into lowercase alphanumeric tokens.
1235
+ * @param {string} identityText Component identity text.
1236
+ * @returns {Set<string>}
1237
+ */
1238
+ static #componentIdentityTokens(identityText) {
1239
+ return new Set(
1240
+ String(identityText || '')
1241
+ .toLowerCase()
1242
+ .split(/[^a-z0-9]+/u)
1243
+ .filter(Boolean)
1244
+ )
1013
1245
  }
1014
1246
 
1015
1247
  /**
@@ -1236,7 +1468,21 @@ export class PcbScene3dBuilder {
1236
1468
  matchContext,
1237
1469
  distanceMil
1238
1470
  ) {
1471
+ const affinityScore =
1472
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
1473
+ componentBody,
1474
+ component
1475
+ )
1476
+ const sideCompatible = PcbScene3dBuilder.#isBodyComponentSideCompatible(
1477
+ componentBody,
1478
+ component
1479
+ )
1480
+
1239
1481
  if (PcbScene3dBuilder.#isPreciseBodyComponentDistance(distanceMil)) {
1482
+ if (!sideCompatible && affinityScore <= 0) {
1483
+ return false
1484
+ }
1485
+
1240
1486
  return !PcbScene3dBuilder.#isIncompatiblePackageBodyMatch(
1241
1487
  componentBody,
1242
1488
  component,
@@ -1244,12 +1490,7 @@ export class PcbScene3dBuilder {
1244
1490
  )
1245
1491
  }
1246
1492
 
1247
- if (
1248
- PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
1249
- componentBody,
1250
- component
1251
- ) <= 0
1252
- ) {
1493
+ if (affinityScore <= 0) {
1253
1494
  return false
1254
1495
  }
1255
1496
 
@@ -1320,17 +1561,19 @@ export class PcbScene3dBuilder {
1320
1561
  }
1321
1562
 
1322
1563
  /**
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.
1564
+ * Checks whether a package-like shape body is an unowned timing-stack
1565
+ * support body that is outside a matching carrier surface.
1325
1566
  * @param {object} componentBody Component-body record.
1326
1567
  * @param {object | null} matchedComponent Matched component.
1327
1568
  * @param {{ designator?: string, x?: number, y?: number, pattern?: string, source?: string, description?: string, provenance?: object, parameters?: object }[]} components PCB components.
1569
+ * @param {object[]} componentBodies All source component bodies.
1328
1570
  * @returns {boolean}
1329
1571
  */
1330
1572
  static #isPositiveTimingStackPackageBody(
1331
1573
  componentBody,
1332
1574
  matchedComponent,
1333
- components
1575
+ components,
1576
+ componentBodies
1334
1577
  ) {
1335
1578
  const standoff = Number(componentBody?.standoffHeightMil)
1336
1579
  const hasTimingOwner = matchedComponent
@@ -1354,7 +1597,107 @@ export class PcbScene3dBuilder {
1354
1597
  !PcbScene3dBuilder.#isTimingStackBodyIdentity(componentBody) &&
1355
1598
  !PcbScene3dBuilder.#isAuthoredBodyIdentity(componentBody) &&
1356
1599
  hasTimingOwner &&
1357
- !hasLocalComponentOwner
1600
+ !hasLocalComponentOwner &&
1601
+ !PcbScene3dBuilder.#isRaisedBodySeatedOnCarrier(
1602
+ componentBody,
1603
+ componentBodies
1604
+ )
1605
+ )
1606
+ }
1607
+
1608
+ /**
1609
+ * Checks whether one raised body sits on a same-height authored carrier.
1610
+ * @param {{ standoffHeightMil?: number, positionMil?: { x?: number, y?: number } }} componentBody Raised component body.
1611
+ * @param {object[]} componentBodies All source component bodies.
1612
+ * @returns {boolean}
1613
+ */
1614
+ static #isRaisedBodySeatedOnCarrier(componentBody, componentBodies) {
1615
+ const standoff = Number(componentBody?.standoffHeightMil)
1616
+ if (!Number.isFinite(standoff) || standoff <= 0) {
1617
+ return false
1618
+ }
1619
+
1620
+ return (Array.isArray(componentBodies) ? componentBodies : []).some(
1621
+ (candidate) =>
1622
+ candidate !== componentBody &&
1623
+ PcbScene3dBuilder.#shouldRenderStaticGeometryOnly(candidate) &&
1624
+ Math.abs(
1625
+ Number(
1626
+ candidate?.staticGeometry?.heightMil ??
1627
+ candidate?.overallHeightMil ??
1628
+ 0
1629
+ ) - standoff
1630
+ ) <= PcbScene3dBuilder.#TIMING_STACK_HEIGHT_TOLERANCE_MIL &&
1631
+ PcbScene3dBuilder.#carrierBoundsContainPoint(
1632
+ candidate,
1633
+ componentBody?.positionMil
1634
+ )
1635
+ )
1636
+ }
1637
+
1638
+ /**
1639
+ * Checks whether a carrier polygon's source or local bounds contain a point.
1640
+ * @param {{ positionMil?: { x?: number, y?: number }, rotationDeg?: number, staticGeometry?: { verticesMil?: object[] } }} carrierBody Carrier body.
1641
+ * @param {{ x?: number, y?: number } | undefined} point Source point.
1642
+ * @returns {boolean}
1643
+ */
1644
+ static #carrierBoundsContainPoint(carrierBody, point) {
1645
+ const vertices = carrierBody?.staticGeometry?.verticesMil
1646
+ if (!Array.isArray(vertices) || vertices.length < 3) {
1647
+ return false
1648
+ }
1649
+
1650
+ const sourcePoint = {
1651
+ x: Number(point?.x),
1652
+ y: Number(point?.y)
1653
+ }
1654
+ if (
1655
+ !Number.isFinite(sourcePoint.x) ||
1656
+ !Number.isFinite(sourcePoint.y)
1657
+ ) {
1658
+ return false
1659
+ }
1660
+
1661
+ const anchor = {
1662
+ x: Number(carrierBody?.positionMil?.x || 0),
1663
+ y: Number(carrierBody?.positionMil?.y || 0)
1664
+ }
1665
+ const rotationRad =
1666
+ (-Number(carrierBody?.rotationDeg || 0) * Math.PI) / 180
1667
+ const dx = sourcePoint.x - anchor.x
1668
+ const dy = sourcePoint.y - anchor.y
1669
+ const localPoint = {
1670
+ x: dx * Math.cos(rotationRad) - dy * Math.sin(rotationRad),
1671
+ y: dx * Math.sin(rotationRad) + dy * Math.cos(rotationRad)
1672
+ }
1673
+
1674
+ return (
1675
+ PcbScene3dBuilder.#boundsContainPoint(sourcePoint, vertices) ||
1676
+ PcbScene3dBuilder.#boundsContainPoint(localPoint, vertices)
1677
+ )
1678
+ }
1679
+
1680
+ /**
1681
+ * Checks whether one axis-aligned vertex bounds contains a point.
1682
+ * @param {{ x: number, y: number }} point Candidate point.
1683
+ * @param {{ x?: number, y?: number }[]} vertices Bounds vertices.
1684
+ * @returns {boolean}
1685
+ */
1686
+ static #boundsContainPoint(point, vertices) {
1687
+ const points = vertices.map((vertex) => ({
1688
+ x: Number(vertex?.x || 0),
1689
+ y: Number(vertex?.y || 0)
1690
+ }))
1691
+ const xs = points.map((vertex) => vertex.x)
1692
+ const ys = points.map((vertex) => vertex.y)
1693
+ const tolerance =
1694
+ PcbScene3dBuilder.#TIMING_STACK_CARRIER_BOUNDS_TOLERANCE_MIL
1695
+
1696
+ return (
1697
+ point.x >= Math.min(...xs) - tolerance &&
1698
+ point.x <= Math.max(...xs) + tolerance &&
1699
+ point.y >= Math.min(...ys) - tolerance &&
1700
+ point.y <= Math.max(...ys) + tolerance
1358
1701
  )
1359
1702
  }
1360
1703
 
@@ -1841,15 +2184,41 @@ export class PcbScene3dBuilder {
1841
2184
  /**
1842
2185
  * Resolves model-local rotations after converting Altium's positive local
1843
2186
  * rotation fields into the renderer's signed 3D model convention.
1844
- * @param {{ modelRotationDeg?: { x?: number, y?: number, z?: number } }} componentBody
2187
+ * @param {{ modelRotationDeg?: { x?: number, y?: number, z?: number } }} componentBody Component body.
2188
+ * @param {{ componentIndex?: number, layer?: string } | null} matchedComponent Matched component.
2189
+ * @param {object[]} pads PCB pads.
2190
+ * @param {'top' | 'bottom'} mountSide Placement mount side.
1845
2191
  * @returns {{ x: number, y: number, z: number }}
1846
2192
  */
1847
- static #resolveExternalModelRotation(componentBody) {
1848
- return {
2193
+ static #resolveExternalModelRotation(
2194
+ componentBody,
2195
+ matchedComponent,
2196
+ pads,
2197
+ mountSide
2198
+ ) {
2199
+ const rotation = {
1849
2200
  x: -Number(componentBody?.modelRotationDeg?.x || 0),
1850
2201
  y: -Number(componentBody?.modelRotationDeg?.y || 0),
1851
2202
  z: 0
1852
2203
  }
2204
+
2205
+ if (
2206
+ mountSide === 'bottom' &&
2207
+ PcbScene3dBuilder.#normalizeAngle(rotation.x) === 180 &&
2208
+ !AltiumScene3dBottomSourceHalfTurnPolicy.shouldPreserve({
2209
+ component: matchedComponent,
2210
+ componentBody,
2211
+ modelTransform: { rotationDeg: rotation }
2212
+ }) &&
2213
+ !PcbScene3dBuilder.#componentHasThroughHolePads(
2214
+ matchedComponent,
2215
+ pads
2216
+ )
2217
+ ) {
2218
+ rotation.x = 0
2219
+ }
2220
+
2221
+ return rotation
1853
2222
  }
1854
2223
 
1855
2224
  /**
@@ -2309,6 +2678,114 @@ export class PcbScene3dBuilder {
2309
2678
  )
2310
2679
  }
2311
2680
 
2681
+ /**
2682
+ * Resolves a component owner when an external body anchor sits on a drilled
2683
+ * pad owned by that component.
2684
+ * @param {{ x?: number, y?: number } | null | undefined} sourcePosition External body anchor.
2685
+ * @param {{ componentIndex?: number, x?: number, y?: number }[]} components PCB components.
2686
+ * @param {object[]} pads PCB pads.
2687
+ * @returns {object | null}
2688
+ */
2689
+ static #resolveComponentFromOwnedDrilledPad(
2690
+ sourcePosition,
2691
+ components,
2692
+ pads
2693
+ ) {
2694
+ const sourceX = Number(sourcePosition?.x)
2695
+ const sourceY = Number(sourcePosition?.y)
2696
+ if (!Number.isFinite(sourceX) || !Number.isFinite(sourceY)) {
2697
+ return null
2698
+ }
2699
+
2700
+ const candidates = (Array.isArray(components) ? components : [])
2701
+ .flatMap((component) =>
2702
+ PcbScene3dBuilder.#componentPads(component, pads)
2703
+ .filter((pad) =>
2704
+ PcbScene3dBuilder.#hasDrilledPadOpening(pad)
2705
+ )
2706
+ .map((pad) => ({
2707
+ component,
2708
+ padDistance: PcbScene3dBuilder.#distanceToPadAnchor(
2709
+ { x: sourceX, y: sourceY },
2710
+ pad
2711
+ ),
2712
+ componentDistance: Math.hypot(
2713
+ Number(component?.x || 0) - sourceX,
2714
+ Number(component?.y || 0) - sourceY
2715
+ )
2716
+ }))
2717
+ )
2718
+ .filter(
2719
+ (candidate) =>
2720
+ candidate.padDistance <=
2721
+ PcbScene3dBuilder.#EXACT_BODY_MISMATCH_TOLERANCE_MIL
2722
+ )
2723
+ .sort(
2724
+ (left, right) =>
2725
+ left.padDistance - right.padDistance ||
2726
+ left.componentDistance - right.componentDistance
2727
+ )
2728
+
2729
+ return candidates[0]?.component || null
2730
+ }
2731
+
2732
+ /**
2733
+ * Measures the distance from a source point to a drilled pad's effective
2734
+ * anchor area.
2735
+ * @param {{ x: number, y: number }} sourcePosition External body anchor.
2736
+ * @param {object} pad PCB pad.
2737
+ * @returns {number}
2738
+ */
2739
+ static #distanceToPadAnchor(sourcePosition, pad) {
2740
+ const centerDistance = Math.hypot(
2741
+ Number(pad?.x || 0) - Number(sourcePosition.x || 0),
2742
+ Number(pad?.y || 0) - Number(sourcePosition.y || 0)
2743
+ )
2744
+ const radius = PcbScene3dBuilder.#padAnchorRadiusMil(pad)
2745
+
2746
+ return radius > 0
2747
+ ? Math.max(0, centerDistance - radius)
2748
+ : Number.POSITIVE_INFINITY
2749
+ }
2750
+
2751
+ /**
2752
+ * Resolves the effective XY radius around a drilled pad center.
2753
+ * @param {object} pad PCB pad.
2754
+ * @returns {number}
2755
+ */
2756
+ static #padAnchorRadiusMil(pad) {
2757
+ const holeGeometry = pad?.holeGeometry || {}
2758
+ const diameter = Math.max(
2759
+ Number(pad?.sizeTopX || 0),
2760
+ Number(pad?.sizeTopY || 0),
2761
+ Number(pad?.sizeMidX || 0),
2762
+ Number(pad?.sizeMidY || 0),
2763
+ Number(pad?.sizeBottomX || 0),
2764
+ Number(pad?.sizeBottomY || 0),
2765
+ Number(pad?.holeDiameter || 0),
2766
+ Number(pad?.drillDiameter || 0),
2767
+ Number(pad?.holeSlotLength || 0),
2768
+ Number(pad?.slotLength || 0),
2769
+ Number(holeGeometry?.diameter || 0),
2770
+ Number(holeGeometry?.length || 0),
2771
+ Number(holeGeometry?.slotLength || 0)
2772
+ )
2773
+
2774
+ return Number.isFinite(diameter) && diameter > 0 ? diameter / 2 : 0
2775
+ }
2776
+
2777
+ /**
2778
+ * Checks whether a component owns drilled or slotted pads.
2779
+ * @param {{ componentIndex?: number, layer?: string } | null} component PCB component.
2780
+ * @param {object[]} pads PCB pads.
2781
+ * @returns {boolean}
2782
+ */
2783
+ static #componentHasThroughHolePads(component, pads) {
2784
+ return PcbScene3dBuilder.#componentPads(component, pads).some((pad) =>
2785
+ PcbScene3dBuilder.#hasDrilledPadOpening(pad)
2786
+ )
2787
+ }
2788
+
2312
2789
  /**
2313
2790
  * Resolves pads explicitly owned by one component, preferring pads on the
2314
2791
  * mounted surface when paste-mask side metadata is available.
@@ -2345,6 +2822,25 @@ export class PcbScene3dBuilder {
2345
2822
  : Boolean(pad?.hasTopPasteMaskOpening)
2346
2823
  }
2347
2824
 
2825
+ /**
2826
+ * Checks whether one pad contains a drilled or slotted board opening.
2827
+ * @param {object} pad PCB pad.
2828
+ * @returns {boolean}
2829
+ */
2830
+ static #hasDrilledPadOpening(pad) {
2831
+ const holeGeometry = pad?.holeGeometry || {}
2832
+
2833
+ return [
2834
+ pad?.holeDiameter,
2835
+ pad?.drillDiameter,
2836
+ pad?.holeSlotLength,
2837
+ pad?.slotLength,
2838
+ holeGeometry?.diameter,
2839
+ holeGeometry?.length,
2840
+ holeGeometry?.slotLength
2841
+ ].some((value) => Number(value || 0) > 0)
2842
+ }
2843
+
2348
2844
  /**
2349
2845
  * Returns true when one pad lies inside the component's local search area.
2350
2846
  * @param {{ x: number, y: number }} component