altium-toolkit 1.1.31 → 1.1.32

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.
@@ -8,9 +8,15 @@ const CONNECTOR_TOKENS = new Set([
8
8
  ])
9
9
  const PASSIVE_BODY_PATTERN =
10
10
  /(?:^|[^a-z0-9])(?:cap|capacitor|res|resistor|ind|inductor|ferrite|bead|lqw|lqg)(?:$|[^a-z0-9])/i
11
+ const COMPACT_IC_BODY_PATTERN =
12
+ /(?:^|[^a-z0-9])(?:u?qfn(?:[-_ ]?\d+)?|dfn(?:[-_ ]?\d+)?|qfp(?:[-_ ]?\d+)?|bga(?:[-_ ]?\d+)?|lga(?:[-_ ]?\d+)?)(?:$|[^a-z0-9])/i
11
13
  const TIMING_PACKAGE_PATTERN =
12
14
  /(?:^|[^a-z0-9])(?:clock|crystal|osc|oscillator|resonator|tcxo|txco|xtal)(?:$|[^a-z0-9])/i
13
15
  const TIMING_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
16
+ const AUTHORED_ANCHOR_IDENTITY_PATTERN =
17
+ /(?:^|[^a-z0-9])(?:antenna|coax|connector|edge|header|jack|mechanical|module|mount|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
18
+ const PAD_FALLBACK_AUTHORED_ANCHOR_PATTERN =
19
+ /(?:^|[^a-z0-9])(?:antenna|coax|conn|connector|edge|flex|fpc|frame|hardware|header|jack|mechanical|module|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
14
20
 
15
21
  /**
16
22
  * Repairs repeated Altium model-anchor bodies by matching their shared source
@@ -19,6 +25,7 @@ const TIMING_DESIGNATOR_PATTERN = /^(?:y|xo)\d+[a-z]?$/i
19
25
  export class AltiumScene3dRepeatedModelOwnerRepair {
20
26
  static #OFFSET_TOLERANCE_MIL = 8
21
27
  static #MIN_OWNER_DISTANCE_MIL = 25
28
+ static #MIN_PAD_FALLBACK_OWNER_OFFSET_MIL = 1
22
29
 
23
30
  /**
24
31
  * Applies repeated-model owner repair to an Altium scene.
@@ -50,7 +57,7 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
50
57
  )
51
58
  const placements = sceneDescription.externalPlacements.map(
52
59
  (placement) =>
53
- AltiumScene3dRepeatedModelOwnerRepair.#withPassiveOwnerCenter(
60
+ AltiumScene3dRepeatedModelOwnerRepair.#withSingleOwnerCenter(
54
61
  placement,
55
62
  componentByDesignator.get(
56
63
  String(placement?.designator || '')
@@ -156,18 +163,18 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
156
163
  }
157
164
 
158
165
  /**
159
- * Centers a generic passive body on its resolved owner when the body anchor
160
- * carries a moderate source-origin offset.
166
+ * Centers a single body on its resolved owner when the body anchor carries
167
+ * a source-origin offset that is not an authored mechanical anchor.
161
168
  * @param {object} placement Scene placement.
162
169
  * @param {object | undefined} component Resolved owner.
163
170
  * @param {object} board Scene board metadata.
164
171
  * @returns {object}
165
172
  */
166
- static #withPassiveOwnerCenter(placement, component, board) {
173
+ static #withSingleOwnerCenter(placement, component, board) {
167
174
  if (
168
175
  !component ||
169
176
  String(placement?.projection?.source || '') !== 'pad-fallback' ||
170
- !AltiumScene3dRepeatedModelOwnerRepair.#isPassivePlacement(
177
+ AltiumScene3dRepeatedModelOwnerRepair.#isAuthoredAnchorPlacement(
171
178
  placement,
172
179
  component
173
180
  )
@@ -185,7 +192,8 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
185
192
  }
186
193
  if (
187
194
  Math.hypot(offset.x, offset.y) <=
188
- AltiumScene3dRepeatedModelOwnerRepair.#MIN_OWNER_DISTANCE_MIL
195
+ AltiumScene3dRepeatedModelOwnerRepair
196
+ .#MIN_PAD_FALLBACK_OWNER_OFFSET_MIL
189
197
  ) {
190
198
  return placement
191
199
  }
@@ -194,28 +202,87 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
194
202
  placement,
195
203
  component,
196
204
  board,
197
- offset
205
+ offset,
206
+ { preserveRotation: true }
198
207
  )
199
208
  }
200
209
 
201
210
  /**
202
- * Checks whether a placement/component pair describes a generic passive.
211
+ * Checks whether a placement/component pair describes an authored hardware
212
+ * anchor whose source body position should remain authoritative.
203
213
  * @param {object} placement Scene placement.
204
214
  * @param {object} component PCB component.
205
215
  * @returns {boolean}
206
216
  */
207
- static #isPassivePlacement(placement, component) {
208
- return PASSIVE_BODY_PATTERN.test(
209
- [
210
- placement?.designator,
211
- placement?.externalModel?.name,
212
- component?.pattern,
213
- component?.source,
214
- component?.description
215
- ]
216
- .map((value) => String(value || ''))
217
- .join(' ')
217
+ static #isAuthoredAnchorPlacement(placement, component) {
218
+ const identityText =
219
+ AltiumScene3dRepeatedModelOwnerRepair.#identityTextForPlacement(
220
+ placement,
221
+ component
222
+ )
223
+ if (PASSIVE_BODY_PATTERN.test(identityText)) {
224
+ return false
225
+ }
226
+
227
+ if (
228
+ String(placement?.projection?.source || '').toLowerCase() ===
229
+ 'pad-fallback'
230
+ ) {
231
+ return (
232
+ PAD_FALLBACK_AUTHORED_ANCHOR_PATTERN.test(identityText) ||
233
+ AltiumScene3dRepeatedModelOwnerRepair.#hasCompactIcSourceOriginOffset(
234
+ placement,
235
+ component,
236
+ identityText
237
+ )
238
+ )
239
+ }
240
+
241
+ return AUTHORED_ANCHOR_IDENTITY_PATTERN.test(identityText)
242
+ }
243
+
244
+ /**
245
+ * Checks whether a compact IC uses a small authored source-origin offset.
246
+ * @param {object} placement Scene placement.
247
+ * @param {object} component PCB component.
248
+ * @param {string} identityText Searchable package identity.
249
+ * @returns {boolean}
250
+ */
251
+ static #hasCompactIcSourceOriginOffset(placement, component, identityText) {
252
+ const distance = AltiumScene3dRepeatedModelOwnerRepair.#distance(
253
+ placement?.bodyPositionMil,
254
+ component
218
255
  )
256
+
257
+ return (
258
+ COMPACT_IC_BODY_PATTERN.test(identityText) &&
259
+ distance >
260
+ AltiumScene3dRepeatedModelOwnerRepair
261
+ .#MIN_PAD_FALLBACK_OWNER_OFFSET_MIL &&
262
+ distance <
263
+ AltiumScene3dRepeatedModelOwnerRepair.#MIN_OWNER_DISTANCE_MIL
264
+ )
265
+ }
266
+
267
+ /**
268
+ * Builds searchable identity text for placement-owner policy checks.
269
+ * @param {object} placement Scene placement.
270
+ * @param {object} component PCB component.
271
+ * @returns {string}
272
+ */
273
+ static #identityTextForPlacement(placement, component) {
274
+ return [
275
+ placement?.designator,
276
+ placement?.externalModel?.name,
277
+ placement?.externalModel?.relativePath,
278
+ placement?.externalModel?.sourceStream,
279
+ component?.pattern,
280
+ component?.source,
281
+ component?.description,
282
+ ...Object.values(component?.parameters || {})
283
+ ]
284
+ .map((value) => String(value || ''))
285
+ .join(' ')
219
286
  }
220
287
 
221
288
  /**
@@ -428,20 +495,32 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
428
495
  * @param {object} component Resolved owner.
429
496
  * @param {object} board Scene board metadata.
430
497
  * @param {{ x: number, y: number }} offset Source-origin offset.
498
+ * @param {{ preserveRotation?: boolean }} [options] Repair options.
431
499
  * @returns {object}
432
500
  */
433
- static #withOwner(placement, component, board, offset) {
501
+ static #withOwner(placement, component, board, offset, options = {}) {
434
502
  const mountSide =
435
503
  AltiumScene3dRepeatedModelOwnerRepair.#mountSide(component) ||
436
504
  placement.mountSide
505
+ const rotationDeg = options?.preserveRotation
506
+ ? AltiumScene3dRepeatedModelOwnerRepair.#normalizeAngle(
507
+ placement?.rotationDeg
508
+ )
509
+ : AltiumScene3dRepeatedModelOwnerRepair.#normalizeAngle(
510
+ component?.rotation
511
+ )
512
+ const renderableOffset =
513
+ AltiumScene3dRepeatedModelOwnerRepair.#renderableOwnerAnchorOffset(
514
+ { mountSide, rotationDeg },
515
+ offset,
516
+ placement?.modelTransform
517
+ )
437
518
 
438
519
  return {
439
520
  ...placement,
440
521
  designator: String(component?.designator || placement.designator),
441
522
  mountSide,
442
- rotationDeg: AltiumScene3dRepeatedModelOwnerRepair.#normalizeAngle(
443
- component?.rotation
444
- ),
523
+ rotationDeg,
445
524
  positionMil: {
446
525
  ...placement.positionMil,
447
526
  x: Number(component?.x || 0) - Number(board?.centerX || 0),
@@ -453,6 +532,7 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
453
532
  },
454
533
  modelTransform: {
455
534
  ...(placement.modelTransform || {}),
535
+ offsetMil: renderableOffset,
456
536
  ownerAnchorOffsetMil: {
457
537
  x: Number(offset?.x || 0),
458
538
  y: Number(offset?.y || 0)
@@ -461,6 +541,52 @@ export class AltiumScene3dRepeatedModelOwnerRepair {
461
541
  }
462
542
  }
463
543
 
544
+ /**
545
+ * Converts a board-space source-origin offset into mount-rig local XY.
546
+ * @param {{ mountSide?: string, rotationDeg?: number }} placement Resolved placement fields.
547
+ * @param {{ x: number, y: number }} offset Board-space owner offset.
548
+ * @param {object | undefined} modelTransform Existing model transform.
549
+ * @returns {{ x: number, y: number, z: number }}
550
+ */
551
+ static #renderableOwnerAnchorOffset(placement, offset, modelTransform) {
552
+ const rotationRad =
553
+ (-AltiumScene3dRepeatedModelOwnerRepair.#normalizeAngle(
554
+ Number(placement?.rotationDeg || 0)
555
+ ) *
556
+ Math.PI) /
557
+ 180
558
+ const cos = Math.cos(rotationRad)
559
+ const sin = Math.sin(rotationRad)
560
+ const x = Number(offset?.x || 0) * cos - Number(offset?.y || 0) * sin
561
+ const y = Number(offset?.x || 0) * sin + Number(offset?.y || 0) * cos
562
+ const z = Number(
563
+ modelTransform?.offsetMil?.z ?? modelTransform?.dzMil ?? 0
564
+ )
565
+
566
+ return {
567
+ x: Math.abs(x) < Number.EPSILON ? 0 : Number(x.toFixed(10)),
568
+ y: AltiumScene3dRepeatedModelOwnerRepair.#isBottomPlacement(
569
+ placement
570
+ )
571
+ ? Math.abs(y) < Number.EPSILON
572
+ ? 0
573
+ : Number((-y).toFixed(10))
574
+ : Math.abs(y) < Number.EPSILON
575
+ ? 0
576
+ : Number(y.toFixed(10)),
577
+ z: Number.isFinite(z) ? z : 0
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Checks whether one placement mounts on the board bottom face.
583
+ * @param {{ mountSide?: string } | null | undefined} placement Placement.
584
+ * @returns {boolean}
585
+ */
586
+ static #isBottomPlacement(placement) {
587
+ return String(placement?.mountSide || '').toLowerCase() === 'bottom'
588
+ }
589
+
464
590
  /**
465
591
  * Scores connector-like identity tokens on one component.
466
592
  * @param {object} component PCB component.
@@ -26,10 +26,16 @@ export class PcbScene3dBuilder {
26
26
  static #DENSE_OVERLAY_MIN_TRACK_COUNT = 250
27
27
  static #DENSE_OVERLAY_KNOCKOUT_COLOR = 0x2f6a2c
28
28
  static #PRECISE_BODY_MATCH_TOLERANCE_MIL = 20
29
+ static #EXACT_BODY_MISMATCH_TOLERANCE_MIL = 1
30
+ static #NEAR_PACKAGE_AFFINITY_DISTANCE_MIL = 100
29
31
  static #UNMATCHED_BODY_OVERHANG_RATIO = 0.25
30
32
  static #UNMATCHED_BODY_MIN_OVERHANG_MIL = 150
31
33
  static #UNMATCHED_BODY_MAX_OVERHANG_MIL = 600
32
34
  static #TRUETYPE_TEXT_WIDTH_RATIO = 0.55
35
+ static #AUTHORED_BODY_IDENTITY_PATTERN =
36
+ /(?:^|[^a-z0-9])(?:antenna|coax|conn|connector|edge|flex|fpc|frame|hardware|header|jack|mechanical|module|mount|shield|sma|socket|usb)(?:$|[^a-z0-9])/i
37
+ static #COMPONENT_PACKAGE_BODY_PATTERN =
38
+ /(?:^|[^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
33
39
 
34
40
  /**
35
41
  * Builds a scene description for host 3D renderers.
@@ -81,10 +87,16 @@ export class PcbScene3dBuilder {
81
87
  : []
82
88
  }
83
89
  const componentBodyModels = componentBodies.map((componentBody) =>
84
- PcbScene3dBuilder.#resolveComponentBodyModel(
90
+ PcbScene3dBuilder.#shouldSuppressLayerlessBodyPlaceholder(
85
91
  componentBody,
86
- modelRegistry
92
+ componentBodies,
93
+ board
87
94
  )
95
+ ? null
96
+ : PcbScene3dBuilder.#resolveComponentBodyModel(
97
+ componentBody,
98
+ modelRegistry
99
+ )
88
100
  )
89
101
  const bodyMatches = PcbScene3dBuilder.#resolveComponentBodyMatches(
90
102
  componentBodies,
@@ -193,12 +205,12 @@ export class PcbScene3dBuilder {
193
205
 
194
206
  /**
195
207
  * Builds one procedural component scene entry.
196
- * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null, source?: string, modelPath?: string }} component
208
+ * @param {{ designator: string, x: number, y: number, layer?: string, pattern?: string, rotation?: number, height?: number | null, source?: string, description?: string, parameters?: Record<string, unknown>, modelPath?: string }} component
197
209
  * @param {{ x: number, y: number, sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }[]} pads
198
210
  * @param {{ centerX: number, centerY: number }} board
199
211
  * @param {number} thicknessMil
200
212
  * @param {{ resolveComponentModel: (component: any) => { name: string, relativePath: string, format: string } | null } | null} modelRegistry
201
- * @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, body: { family: string, sizeMil: { width: number, depth: number, height: number } }, externalModel: { name: string, relativePath: string, format: string } | null }}
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 }}
202
214
  */
203
215
  static #buildComponent(
204
216
  component,
@@ -233,6 +245,13 @@ export class PcbScene3dBuilder {
233
245
  },
234
246
  pattern: String(component.pattern || ''),
235
247
  source: String(component.source || ''),
248
+ description: String(component.description || ''),
249
+ parameters:
250
+ component.parameters &&
251
+ typeof component.parameters === 'object' &&
252
+ !Array.isArray(component.parameters)
253
+ ? { ...component.parameters }
254
+ : {},
236
255
  body,
237
256
  externalModel: modelRegistry
238
257
  ? modelRegistry.resolveComponentModel(component)
@@ -275,6 +294,16 @@ export class PcbScene3dBuilder {
275
294
  return null
276
295
  }
277
296
 
297
+ if (
298
+ !matchedComponent &&
299
+ PcbScene3dBuilder.#shouldDropUnmatchedPackageBody(
300
+ componentBody,
301
+ components
302
+ )
303
+ ) {
304
+ return null
305
+ }
306
+
278
307
  if (
279
308
  !matchedComponent &&
280
309
  !PcbScene3dBuilder.#isBodyPositionNearBoard(componentBody, board)
@@ -540,6 +569,12 @@ export class PcbScene3dBuilder {
540
569
  bodyIndex,
541
570
  componentIndex,
542
571
  affinityScore,
572
+ nearPackageAffinityScore:
573
+ PcbScene3dBuilder.#nearPackageAffinityScore(
574
+ componentBody,
575
+ affinityScore,
576
+ distance
577
+ ),
543
578
  preciseOwnerScore:
544
579
  precise && (sideCompatible || affinityScore > 0)
545
580
  ? 1
@@ -555,6 +590,8 @@ export class PcbScene3dBuilder {
555
590
  closeCandidates
556
591
  .sort(
557
592
  (left, right) =>
593
+ right.nearPackageAffinityScore -
594
+ left.nearPackageAffinityScore ||
558
595
  right.preciseOwnerScore - left.preciseOwnerScore ||
559
596
  right.sideAffinityScore - left.sideAffinityScore ||
560
597
  right.affinityScore - left.affinityScore ||
@@ -804,7 +841,11 @@ export class PcbScene3dBuilder {
804
841
  distanceMil
805
842
  ) {
806
843
  if (PcbScene3dBuilder.#isPreciseBodyComponentDistance(distanceMil)) {
807
- return true
844
+ return !PcbScene3dBuilder.#isIncompatiblePackageBodyMatch(
845
+ componentBody,
846
+ component,
847
+ distanceMil
848
+ )
808
849
  }
809
850
 
810
851
  if (
@@ -825,6 +866,242 @@ export class PcbScene3dBuilder {
825
866
  return bodyCount > 0 && bodyCount <= candidateCount
826
867
  }
827
868
 
869
+ /**
870
+ * Scores short-range package metadata matches above no-affinity anchors.
871
+ * @param {{ name?: string, identifier?: string }} componentBody Component-body record.
872
+ * @param {number} affinityScore Shared body/component identity score.
873
+ * @param {number} distanceMil Body/component anchor distance.
874
+ * @returns {number}
875
+ */
876
+ static #nearPackageAffinityScore(
877
+ componentBody,
878
+ affinityScore,
879
+ distanceMil
880
+ ) {
881
+ return PcbScene3dBuilder.#isComponentPackageBody(componentBody) &&
882
+ Number(affinityScore || 0) > 0 &&
883
+ Number(distanceMil || 0) <=
884
+ PcbScene3dBuilder.#NEAR_PACKAGE_AFFINITY_DISTANCE_MIL
885
+ ? 1
886
+ : 0
887
+ }
888
+
889
+ /**
890
+ * Checks whether an unmatched package-like body should be suppressed
891
+ * because it sits on an exact but incompatible component anchor.
892
+ * @param {{ name?: string, identifier?: string, positionMil?: { x?: number, y?: number } }} componentBody Component-body record.
893
+ * @param {{ x: number, y: number, pattern?: string, source?: string, modelPath?: string }[]} components PCB components.
894
+ * @returns {boolean}
895
+ */
896
+ static #shouldDropUnmatchedPackageBody(componentBody, components) {
897
+ if (
898
+ !PcbScene3dBuilder.#isComponentPackageBody(componentBody) ||
899
+ PcbScene3dBuilder.#isAuthoredBodyIdentity(componentBody)
900
+ ) {
901
+ return false
902
+ }
903
+
904
+ const exactComponents = (
905
+ Array.isArray(components) ? components : []
906
+ ).filter(
907
+ (component) =>
908
+ PcbScene3dBuilder.#distanceBetweenBodyAndComponent(
909
+ componentBody,
910
+ component
911
+ ) <= PcbScene3dBuilder.#EXACT_BODY_MISMATCH_TOLERANCE_MIL
912
+ )
913
+
914
+ return (
915
+ exactComponents.length > 0 &&
916
+ exactComponents.every(
917
+ (component) =>
918
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
919
+ componentBody,
920
+ component
921
+ ) <= 0
922
+ )
923
+ )
924
+ }
925
+
926
+ /**
927
+ * Checks whether a precise body/component pair is a package-family mismatch.
928
+ * @param {{ name?: string, identifier?: string }} componentBody Component-body record.
929
+ * @param {{ pattern?: string, source?: string, modelPath?: string }} component PCB component.
930
+ * @param {number} distanceMil Body/component anchor distance.
931
+ * @returns {boolean}
932
+ */
933
+ static #isIncompatiblePackageBodyMatch(
934
+ componentBody,
935
+ component,
936
+ distanceMil
937
+ ) {
938
+ return (
939
+ Number(distanceMil || 0) <=
940
+ PcbScene3dBuilder.#EXACT_BODY_MISMATCH_TOLERANCE_MIL &&
941
+ PcbScene3dBuilder.#isComponentPackageBody(componentBody) &&
942
+ !PcbScene3dBuilder.#isAuthoredBodyIdentity(componentBody) &&
943
+ PcbScene3dPlacementSideResolver.scoreBodyComponentAffinity(
944
+ componentBody,
945
+ component
946
+ ) <= 0
947
+ )
948
+ }
949
+
950
+ /**
951
+ * Checks whether a component body names a package model rather than an
952
+ * authored board mechanical.
953
+ * @param {{ name?: string, identifier?: string }} componentBody Component-body record.
954
+ * @returns {boolean}
955
+ */
956
+ static #isComponentPackageBody(componentBody) {
957
+ return PcbScene3dBuilder.#COMPONENT_PACKAGE_BODY_PATTERN.test(
958
+ PcbScene3dBuilder.#componentBodyIdentityText(componentBody)
959
+ )
960
+ }
961
+
962
+ /**
963
+ * Checks whether a component body identity describes an authored hardware
964
+ * or connector anchor that can legitimately sit away from a footprint
965
+ * center.
966
+ * @param {{ name?: string, identifier?: string }} componentBody Component-body record.
967
+ * @returns {boolean}
968
+ */
969
+ static #isAuthoredBodyIdentity(componentBody) {
970
+ return PcbScene3dBuilder.#AUTHORED_BODY_IDENTITY_PATTERN.test(
971
+ PcbScene3dBuilder.#componentBodyIdentityText(componentBody)
972
+ )
973
+ }
974
+
975
+ /**
976
+ * Checks whether a layerless component body is a footprint-library
977
+ * placeholder rather than a board-side placement.
978
+ * @param {{ layer?: string, name?: string, identifier?: string, modelId?: string, checksum?: number | null, positionMil?: { x?: number, y?: number } }} componentBody Component body record.
979
+ * @param {object[]} componentBodies All component body records.
980
+ * @param {{ minX: number, minY: number, widthMil: number, heightMil: number }} board Board envelope.
981
+ * @returns {boolean}
982
+ */
983
+ static #shouldSuppressLayerlessBodyPlaceholder(
984
+ componentBody,
985
+ componentBodies,
986
+ board
987
+ ) {
988
+ if (
989
+ !PcbScene3dBuilder.#isLayerlessComponentBody(componentBody) ||
990
+ PcbScene3dBuilder.#isAuthoredBodyIdentity(componentBody)
991
+ ) {
992
+ return false
993
+ }
994
+
995
+ return (
996
+ !PcbScene3dBuilder.#isBodyPositionNearBoard(componentBody, board) ||
997
+ PcbScene3dBuilder.#hasLayeredEquivalentPackageBody(
998
+ componentBody,
999
+ componentBodies
1000
+ )
1001
+ )
1002
+ }
1003
+
1004
+ /**
1005
+ * Checks whether a body lacks an authored mechanical layer.
1006
+ * @param {{ layer?: string }} componentBody Component body record.
1007
+ * @returns {boolean}
1008
+ */
1009
+ static #isLayerlessComponentBody(componentBody) {
1010
+ return String(componentBody?.layer || '').trim() === ''
1011
+ }
1012
+
1013
+ /**
1014
+ * Checks whether a layerless body duplicates a real board-side body.
1015
+ * @param {{ positionMil?: { x?: number, y?: number } }} componentBody Candidate layerless body.
1016
+ * @param {object[]} componentBodies All component body records.
1017
+ * @returns {boolean}
1018
+ */
1019
+ static #hasLayeredEquivalentPackageBody(componentBody, componentBodies) {
1020
+ return (Array.isArray(componentBodies) ? componentBodies : []).some(
1021
+ (candidate) =>
1022
+ candidate !== componentBody &&
1023
+ !PcbScene3dBuilder.#isLayerlessComponentBody(candidate) &&
1024
+ PcbScene3dBuilder.#componentBodiesShareModelIdentity(
1025
+ componentBody,
1026
+ candidate
1027
+ ) &&
1028
+ PcbScene3dBuilder.#distanceBetweenBodies(
1029
+ componentBody,
1030
+ candidate
1031
+ ) <= PcbScene3dBuilder.#EXACT_BODY_MISMATCH_TOLERANCE_MIL
1032
+ )
1033
+ }
1034
+
1035
+ /**
1036
+ * Checks whether two component bodies refer to the same external package.
1037
+ * @param {{ name?: string, identifier?: string, modelId?: string, checksum?: number | null }} left First body.
1038
+ * @param {{ name?: string, identifier?: string, modelId?: string, checksum?: number | null }} right Second body.
1039
+ * @returns {boolean}
1040
+ */
1041
+ static #componentBodiesShareModelIdentity(left, right) {
1042
+ const leftModelId = PcbScene3dBuilder.#normalizeIdentityToken(
1043
+ left?.modelId
1044
+ )
1045
+ const rightModelId = PcbScene3dBuilder.#normalizeIdentityToken(
1046
+ right?.modelId
1047
+ )
1048
+ if (leftModelId && rightModelId && leftModelId === rightModelId) {
1049
+ return true
1050
+ }
1051
+
1052
+ const leftChecksum = Number(left?.checksum)
1053
+ const rightChecksum = Number(right?.checksum)
1054
+ if (
1055
+ Number.isFinite(leftChecksum) &&
1056
+ Number.isFinite(rightChecksum) &&
1057
+ leftChecksum === rightChecksum
1058
+ ) {
1059
+ return true
1060
+ }
1061
+
1062
+ const leftName = PcbScene3dBuilder.#normalizeBodyNameToken(left)
1063
+ const rightName = PcbScene3dBuilder.#normalizeBodyNameToken(right)
1064
+
1065
+ return Boolean(leftName && rightName && leftName === rightName)
1066
+ }
1067
+
1068
+ /**
1069
+ * Normalizes a body model name or identifier for duplicate detection.
1070
+ * @param {{ name?: string, identifier?: string }} componentBody Component body.
1071
+ * @returns {string}
1072
+ */
1073
+ static #normalizeBodyNameToken(componentBody) {
1074
+ return PcbScene3dBuilder.#normalizeIdentityToken(
1075
+ componentBody?.name || componentBody?.identifier
1076
+ ).replace(/\.[a-z0-9]+$/i, '')
1077
+ }
1078
+
1079
+ /**
1080
+ * Normalizes an identity token for case-insensitive comparisons.
1081
+ * @param {unknown} value Raw token.
1082
+ * @returns {string}
1083
+ */
1084
+ static #normalizeIdentityToken(value) {
1085
+ return String(value || '')
1086
+ .trim()
1087
+ .toLowerCase()
1088
+ }
1089
+
1090
+ /**
1091
+ * Builds searchable identity text for one component body.
1092
+ * @param {{ name?: string, identifier?: string, modelId?: string }} componentBody Component-body record.
1093
+ * @returns {string}
1094
+ */
1095
+ static #componentBodyIdentityText(componentBody) {
1096
+ return [
1097
+ componentBody?.identifier,
1098
+ componentBody?.name,
1099
+ componentBody?.modelId
1100
+ ]
1101
+ .map((value) => String(value || ''))
1102
+ .join(' ')
1103
+ }
1104
+
828
1105
  /**
829
1106
  * Pairs one unresolved repeated body group with a repeated component group
830
1107
  * by preserving the dominant ordering axis and choosing the pairing that
@@ -1541,6 +1818,21 @@ export class PcbScene3dBuilder {
1541
1818
  )
1542
1819
  }
1543
1820
 
1821
+ /**
1822
+ * Returns the euclidean distance between two component body anchors.
1823
+ * @param {{ positionMil?: { x?: number, y?: number } }} left First body.
1824
+ * @param {{ positionMil?: { x?: number, y?: number } }} right Second body.
1825
+ * @returns {number}
1826
+ */
1827
+ static #distanceBetweenBodies(left, right) {
1828
+ return Math.hypot(
1829
+ Number(left?.positionMil?.x || 0) -
1830
+ Number(right?.positionMil?.x || 0),
1831
+ Number(left?.positionMil?.y || 0) -
1832
+ Number(right?.positionMil?.y || 0)
1833
+ )
1834
+ }
1835
+
1544
1836
  /**
1545
1837
  * Returns true when one unresolved body anchor still lies close enough to
1546
1838
  * the board envelope to be renderable without a component match.