pcb-scene3d-viewer 1.1.1 → 1.1.2

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.
@@ -3,6 +3,7 @@ import { PcbScene3dBoardAssemblyPlacement } from './PcbScene3dBoardAssemblyPlace
3
3
  import { PcbScene3dBoardAssemblyTransform } from './PcbScene3dBoardAssemblyTransform.mjs'
4
4
  import { PcbScene3dBufferAttributeFactory } from './PcbScene3dBufferAttributeFactory.mjs'
5
5
  import { PcbScene3dExternalModelLoadOrder } from './PcbScene3dExternalModelLoadOrder.mjs'
6
+ import { PcbScene3dModelBounds } from './PcbScene3dModelBounds.mjs'
6
7
  import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
7
8
  import { PcbScene3dStepLoader } from './PcbScene3dStepLoader.mjs'
8
9
  import { PcbScene3dViewCompensation } from './PcbScene3dViewCompensation.mjs'
@@ -270,10 +271,18 @@ export class PcbScene3dExternalModels {
270
271
  const mountRig = PcbScene3dMountRig.create(THREE, placement)
271
272
  const wrapperGroup = mountRig.rootGroup
272
273
  const viewCompensationGroup = new THREE.Group()
274
+ const adjustmentGroup = new THREE.Group()
275
+ const designator = String(placement?.designator || 'component')
273
276
  wrapperGroup.userData.scene3dSelection = {
274
- designator: String(placement?.designator || 'component'),
277
+ designator,
275
278
  sourceType: 'external-model'
276
279
  }
280
+ adjustmentGroup.userData.scene3dAdjustmentTarget = true
281
+ adjustmentGroup.userData.scene3dAdjustmentDesignator = designator
282
+ adjustmentGroup.userData.scene3dAdjustmentBaseline =
283
+ PcbScene3dExternalModels.#normalizeModelTransform(
284
+ placement?.modelTransform || {}
285
+ )
277
286
  viewCompensationGroup.userData.scene3dViewCompensation = true
278
287
  viewCompensationGroup.userData.scene3dSourceFrameScale =
279
288
  PcbScene3dExternalModels.#resolveSourceFrameScale(placement)
@@ -312,7 +321,7 @@ export class PcbScene3dExternalModels {
312
321
  modelGroup.position.set(
313
322
  modelOffset.x + sourceOriginAdjustment.offset.x,
314
323
  modelOffset.y + sourceOriginAdjustment.offset.y,
315
- modelOffset.z + sourceOriginAdjustment.offset.z
324
+ sourceOriginAdjustment.offset.z
316
325
  )
317
326
  PcbScene3dExternalModels.#applyModelRotation(
318
327
  THREE,
@@ -323,7 +332,10 @@ export class PcbScene3dExternalModels {
323
332
  modelGroup,
324
333
  PcbScene3dExternalModels.#resolveModelScale(modelTransform)
325
334
  )
326
- mountRig.faceGroup.add(modelGroup)
335
+ PcbScene3dModelBounds.seatOnMountPlane(THREE, modelGroup)
336
+ modelGroup.position.z += modelOffset.z
337
+ adjustmentGroup.add(modelGroup)
338
+ mountRig.faceGroup.add(adjustmentGroup)
327
339
 
328
340
  return wrapperGroup
329
341
  }
@@ -478,18 +490,50 @@ export class PcbScene3dExternalModels {
478
490
  }
479
491
 
480
492
  if (Math.abs(centerX) > maxDimension * 0.2) {
493
+ if (centerX * centerZ <= 0) {
494
+ return {
495
+ offset: { x: centerX * 2, y: -centerZ * 2, z: 0 },
496
+ rotationDeg: { x: 0, y: 0, z: 180 }
497
+ }
498
+ }
499
+
481
500
  return {
482
501
  offset: { x: 0, y: 0, z: 0 },
483
502
  rotationDeg: { x: 0, y: 0, z: 180 }
484
503
  }
485
504
  }
486
505
 
506
+ if (PcbScene3dExternalModels.#isDominantSourceZExtension(bounds)) {
507
+ return PcbScene3dExternalModels.#emptySourceOriginAdjustment()
508
+ }
509
+
487
510
  return {
488
511
  offset: { x: 0, y: centerZ * 2, z: 0 },
489
512
  rotationDeg: { x: 0, y: 0, z: 0 }
490
513
  }
491
514
  }
492
515
 
516
+ /**
517
+ * Checks whether source Z is the model's intentional edge-extension axis
518
+ * instead of a square-package source-origin bias.
519
+ * @param {{ sizeX?: number, sizeY?: number, sizeZ?: number } | null} bounds Source bounds.
520
+ * @returns {boolean}
521
+ */
522
+ static #isDominantSourceZExtension(bounds) {
523
+ const sourceZ = Math.abs(Number(bounds?.sizeZ || 0))
524
+ const lateralSize = Math.max(
525
+ Math.abs(Number(bounds?.sizeX || 0)),
526
+ Math.abs(Number(bounds?.sizeY || 0))
527
+ )
528
+
529
+ return (
530
+ Number.isFinite(sourceZ) &&
531
+ Number.isFinite(lateralSize) &&
532
+ lateralSize > 0 &&
533
+ sourceZ > lateralSize * 2
534
+ )
535
+ }
536
+
493
537
  /**
494
538
  * Returns the neutral embedded-source adjustment shape.
495
539
  * @returns {{ offset: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number } }}
@@ -711,7 +755,7 @@ export class PcbScene3dExternalModels {
711
755
  ? { meshPayloads: model.preparedMeshPayloads }
712
756
  : await stepLoader.loadModel(model)
713
757
  const group = new THREE.Group()
714
- const sourceBounds = PcbScene3dExternalModels.#measureSourceBoundsMil(
758
+ const sourceBounds = PcbScene3dModelBounds.measureSourceBoundsMil(
715
759
  loadedModel.meshPayloads
716
760
  )
717
761
  group.scale.setScalar(1000)
@@ -766,71 +810,6 @@ export class PcbScene3dExternalModels {
766
810
  return group
767
811
  }
768
812
 
769
- /**
770
- * Measures raw STEP mesh bounds in mil before the group-level unit scale is
771
- * applied.
772
- * @param {{ positions?: ArrayLike<number> }[]} meshPayloads STEP mesh payloads.
773
- * @returns {{ minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number, centerX: number, centerY: number, centerZ: number, sizeX: number, sizeY: number, sizeZ: number } | null}
774
- */
775
- static #measureSourceBoundsMil(meshPayloads) {
776
- let minX = Number.POSITIVE_INFINITY
777
- let minY = Number.POSITIVE_INFINITY
778
- let minZ = Number.POSITIVE_INFINITY
779
- let maxX = Number.NEGATIVE_INFINITY
780
- let maxY = Number.NEGATIVE_INFINITY
781
- let maxZ = Number.NEGATIVE_INFINITY
782
-
783
- ;(Array.isArray(meshPayloads) ? meshPayloads : []).forEach(
784
- (meshPayload) => {
785
- ;(PcbScene3dBufferAttributeFactory.isNumberSequence(
786
- meshPayload?.positions
787
- )
788
- ? meshPayload.positions
789
- : []
790
- ).forEach((value, index) => {
791
- const numericValue = Number(value || 0) * 1000
792
-
793
- if (index % 3 === 0) {
794
- minX = Math.min(minX, numericValue)
795
- maxX = Math.max(maxX, numericValue)
796
- } else if (index % 3 === 1) {
797
- minY = Math.min(minY, numericValue)
798
- maxY = Math.max(maxY, numericValue)
799
- } else {
800
- minZ = Math.min(minZ, numericValue)
801
- maxZ = Math.max(maxZ, numericValue)
802
- }
803
- })
804
- }
805
- )
806
-
807
- if (
808
- !Number.isFinite(minX) ||
809
- !Number.isFinite(minY) ||
810
- !Number.isFinite(minZ) ||
811
- !Number.isFinite(maxX) ||
812
- !Number.isFinite(maxY) ||
813
- !Number.isFinite(maxZ)
814
- ) {
815
- return null
816
- }
817
-
818
- return {
819
- minX,
820
- minY,
821
- minZ,
822
- maxX,
823
- maxY,
824
- maxZ,
825
- centerX: (minX + maxX) / 2,
826
- centerY: (minY + maxY) / 2,
827
- centerZ: (minZ + maxZ) / 2,
828
- sizeX: maxX - minX,
829
- sizeY: maxY - minY,
830
- sizeZ: maxZ - minZ
831
- }
832
- }
833
-
834
813
  /**
835
814
  * Builds the material set for one STEP mesh and assigns face-color groups
836
815
  * when the importer exposes them.
@@ -895,11 +874,17 @@ export class PcbScene3dExternalModels {
895
874
  * @returns {any}
896
875
  */
897
876
  static #createStepMaterial(THREE, color) {
898
- return new THREE.MeshStandardMaterial({
877
+ const options = {
899
878
  color,
900
879
  roughness: 0.56,
901
880
  metalness: 0.14
902
- })
881
+ }
882
+
883
+ if (THREE.DoubleSide !== undefined) {
884
+ options.side = THREE.DoubleSide
885
+ }
886
+
887
+ return new THREE.MeshStandardMaterial(options)
903
888
  }
904
889
 
905
890
  /**
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Resolves lightweight XY bounds for Three.js-style geometry attributes.
3
+ */
4
+ export class PcbScene3dGeometryBoundsResolver {
5
+ /**
6
+ * Resolves the XY bounds for one position attribute.
7
+ * @param {{ count?: number, getX?: (index: number) => number, getY?: (index: number) => number }} position Position attribute.
8
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
9
+ */
10
+ static resolvePositionBounds(position) {
11
+ const bounds = {
12
+ minX: Infinity,
13
+ maxX: -Infinity,
14
+ minY: Infinity,
15
+ maxY: -Infinity
16
+ }
17
+
18
+ for (let index = 0; index < Number(position?.count || 0); index += 1) {
19
+ const x = Number(position.getX?.(index) || 0)
20
+ const y = Number(position.getY?.(index) || 0)
21
+
22
+ bounds.minX = Math.min(bounds.minX, x)
23
+ bounds.maxX = Math.max(bounds.maxX, x)
24
+ bounds.minY = Math.min(bounds.minY, y)
25
+ bounds.maxY = Math.max(bounds.maxY, y)
26
+ }
27
+
28
+ return bounds
29
+ }
30
+
31
+ /**
32
+ * Returns true when one bounds box overlaps any entry bounds.
33
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds Bounds to test.
34
+ * @param {{ bounds?: { minX: number, maxX: number, minY: number, maxY: number } }[]} entries Entries with bounds.
35
+ * @param {number} [epsilon] Overlap tolerance.
36
+ * @returns {boolean}
37
+ */
38
+ static overlapsAny(bounds, entries, epsilon = 0) {
39
+ return (Array.isArray(entries) ? entries : []).some((entry) =>
40
+ PcbScene3dGeometryBoundsResolver.#boundsOverlap(
41
+ bounds,
42
+ entry?.bounds,
43
+ epsilon
44
+ )
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Returns true when one position attribute cannot overlap any entry bounds.
50
+ * @param {{ count?: number, getX?: (index: number) => number, getY?: (index: number) => number }} position Position attribute.
51
+ * @param {{ bounds?: { minX: number, maxX: number, minY: number, maxY: number } }[]} entries Entries with bounds.
52
+ * @param {number} [epsilon] Overlap tolerance.
53
+ * @returns {boolean}
54
+ */
55
+ static missesAllPositionBounds(position, entries, epsilon = 0) {
56
+ return !PcbScene3dGeometryBoundsResolver.overlapsAny(
57
+ PcbScene3dGeometryBoundsResolver.resolvePositionBounds(position),
58
+ entries,
59
+ epsilon
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Returns true when two bounding boxes overlap.
65
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} first First bounds.
66
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number } | undefined} second Second bounds.
67
+ * @param {number} epsilon Overlap tolerance.
68
+ * @returns {boolean}
69
+ */
70
+ static #boundsOverlap(first, second, epsilon) {
71
+ return (
72
+ first.minX <= Number(second?.maxX) + epsilon &&
73
+ first.maxX >= Number(second?.minX) - epsilon &&
74
+ first.minY <= Number(second?.maxY) + epsilon &&
75
+ first.maxY >= Number(second?.minY) - epsilon
76
+ )
77
+ }
78
+ }
@@ -0,0 +1,267 @@
1
+ import { PcbScene3dBufferAttributeFactory } from './PcbScene3dBufferAttributeFactory.mjs'
2
+
3
+ /**
4
+ * Measures and normalizes loaded 3D model bounds.
5
+ */
6
+ export class PcbScene3dModelBounds {
7
+ static #POSITION_EPSILON = 1e-9
8
+ static #SUPPORT_BUCKET_MIL = 5
9
+ static #LOWER_SUPPORT_WINDOW_RATIO = 0.14
10
+ static #LOWER_SUPPORT_WINDOW_MAX_MIL = 30
11
+ static #LOWER_SUPPORT_MIN_GAP_MIL = 1
12
+ static #SUPPORT_BUCKET_MIN_DENSITY = 0.2
13
+
14
+ /**
15
+ * Seats one transformed model group on its local mount plane.
16
+ * @param {any} THREE Three.js namespace.
17
+ * @param {{ position?: { z?: number }, updateMatrixWorld?: (force?: boolean) => void }} modelGroup Model group.
18
+ * @returns {void}
19
+ */
20
+ static seatOnMountPlane(THREE, modelGroup) {
21
+ if (!THREE?.Box3 || !modelGroup?.position) {
22
+ return
23
+ }
24
+
25
+ modelGroup.updateMatrixWorld?.(true)
26
+ const bounds = new THREE.Box3().setFromObject(modelGroup)
27
+ const currentZ = Number(modelGroup.position.z || 0)
28
+ const modelMinZ = Number(bounds?.min?.z) - currentZ
29
+ const mountPlaneZ = PcbScene3dModelBounds.#resolveMountPlaneZ(
30
+ THREE,
31
+ modelGroup,
32
+ modelMinZ
33
+ )
34
+
35
+ if (!Number.isFinite(mountPlaneZ)) {
36
+ return
37
+ }
38
+
39
+ const adjustedZ = modelGroup.position.z - mountPlaneZ
40
+ modelGroup.position.z =
41
+ Math.abs(adjustedZ) <= PcbScene3dModelBounds.#POSITION_EPSILON
42
+ ? 0
43
+ : adjustedZ
44
+ modelGroup.updateMatrixWorld?.(true)
45
+ }
46
+
47
+ /**
48
+ * Measures raw STEP mesh bounds in mil before the group-level unit scale is
49
+ * applied.
50
+ * @param {{ positions?: ArrayLike<number> }[]} meshPayloads STEP mesh payloads.
51
+ * @returns {{ minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number, centerX: number, centerY: number, centerZ: number, sizeX: number, sizeY: number, sizeZ: number } | null}
52
+ */
53
+ static measureSourceBoundsMil(meshPayloads) {
54
+ let minX = Number.POSITIVE_INFINITY
55
+ let minY = Number.POSITIVE_INFINITY
56
+ let minZ = Number.POSITIVE_INFINITY
57
+ let maxX = Number.NEGATIVE_INFINITY
58
+ let maxY = Number.NEGATIVE_INFINITY
59
+ let maxZ = Number.NEGATIVE_INFINITY
60
+
61
+ ;(Array.isArray(meshPayloads) ? meshPayloads : []).forEach(
62
+ (meshPayload) => {
63
+ ;(PcbScene3dBufferAttributeFactory.isNumberSequence(
64
+ meshPayload?.positions
65
+ )
66
+ ? meshPayload.positions
67
+ : []
68
+ ).forEach((value, index) => {
69
+ const numericValue = Number(value || 0) * 1000
70
+
71
+ if (index % 3 === 0) {
72
+ minX = Math.min(minX, numericValue)
73
+ maxX = Math.max(maxX, numericValue)
74
+ } else if (index % 3 === 1) {
75
+ minY = Math.min(minY, numericValue)
76
+ maxY = Math.max(maxY, numericValue)
77
+ } else {
78
+ minZ = Math.min(minZ, numericValue)
79
+ maxZ = Math.max(maxZ, numericValue)
80
+ }
81
+ })
82
+ }
83
+ )
84
+
85
+ if (
86
+ !Number.isFinite(minX) ||
87
+ !Number.isFinite(minY) ||
88
+ !Number.isFinite(minZ) ||
89
+ !Number.isFinite(maxX) ||
90
+ !Number.isFinite(maxY) ||
91
+ !Number.isFinite(maxZ)
92
+ ) {
93
+ return null
94
+ }
95
+
96
+ return {
97
+ minX,
98
+ minY,
99
+ minZ,
100
+ maxX,
101
+ maxY,
102
+ maxZ,
103
+ centerX: (minX + maxX) / 2,
104
+ centerY: (minY + maxY) / 2,
105
+ centerZ: (minZ + maxZ) / 2,
106
+ sizeX: maxX - minX,
107
+ sizeY: maxY - minY,
108
+ sizeZ: maxZ - minZ
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Resolves the model-local plane that should touch the board face.
114
+ * @param {any} THREE Three.js namespace.
115
+ * @param {{ position?: { z?: number }, traverse?: (callback: (object: any) => void) => void, updateMatrixWorld?: (force?: boolean) => void }} modelGroup Model group.
116
+ * @param {number} fallbackMinZ Absolute lower bound fallback.
117
+ * @returns {number}
118
+ */
119
+ static #resolveMountPlaneZ(THREE, modelGroup, fallbackMinZ) {
120
+ const vertexZ = PcbScene3dModelBounds.#collectTransformedVertexZ(
121
+ THREE,
122
+ modelGroup
123
+ )
124
+ const dominantPlane =
125
+ PcbScene3dModelBounds.#resolveDominantLowerPlaneZ(vertexZ)
126
+
127
+ return Number.isFinite(dominantPlane) ? dominantPlane : fallbackMinZ
128
+ }
129
+
130
+ /**
131
+ * Collects transformed vertex Z coordinates in the model group's parent
132
+ * frame before the current model-group Z offset is applied.
133
+ * @param {any} THREE Three.js namespace.
134
+ * @param {{ position?: { z?: number }, parent?: any, matrixWorld?: any, traverse?: (callback: (object: any) => void) => void, updateMatrixWorld?: (force?: boolean) => void }} modelGroup Model group.
135
+ * @returns {number[]}
136
+ */
137
+ static #collectTransformedVertexZ(THREE, modelGroup) {
138
+ if (!THREE?.Vector3 || typeof modelGroup?.traverse !== 'function') {
139
+ return []
140
+ }
141
+
142
+ modelGroup.updateMatrixWorld?.(true)
143
+ modelGroup.parent?.updateMatrixWorld?.(true)
144
+ const currentZ = Number(modelGroup?.position?.z || 0)
145
+ const parentInverse =
146
+ THREE?.Matrix4 && modelGroup.parent?.matrixWorld
147
+ ? new THREE.Matrix4()
148
+ .copy(modelGroup.parent.matrixWorld)
149
+ .invert()
150
+ : null
151
+ const vertex = new THREE.Vector3()
152
+ const values = []
153
+
154
+ modelGroup.traverse((object) => {
155
+ const position = object?.geometry?.attributes?.position
156
+ if (!position || !object?.matrixWorld) {
157
+ return
158
+ }
159
+
160
+ for (
161
+ let index = 0;
162
+ index < Number(position.count || 0);
163
+ index += 1
164
+ ) {
165
+ vertex.fromBufferAttribute(position, index)
166
+ vertex.applyMatrix4(object.matrixWorld)
167
+ if (parentInverse) {
168
+ vertex.applyMatrix4(parentInverse)
169
+ }
170
+ values.push(vertex.z - currentZ)
171
+ }
172
+ })
173
+
174
+ return values.filter((value) => Number.isFinite(value))
175
+ }
176
+
177
+ /**
178
+ * Finds a dense lower support plane above sparse pin or lead protrusions.
179
+ * @param {number[]} values Model-local transformed Z coordinates.
180
+ * @returns {number | null}
181
+ */
182
+ static #resolveDominantLowerPlaneZ(values) {
183
+ if (!Array.isArray(values) || values.length < 3) {
184
+ return null
185
+ }
186
+
187
+ const { minZ, maxZ } = PcbScene3dModelBounds.#resolveZExtents(values)
188
+ const height = maxZ - minZ
189
+ if (!Number.isFinite(height) || height <= 0) {
190
+ return null
191
+ }
192
+
193
+ const lowerWindow = Math.min(
194
+ PcbScene3dModelBounds.#LOWER_SUPPORT_WINDOW_MAX_MIL,
195
+ height * PcbScene3dModelBounds.#LOWER_SUPPORT_WINDOW_RATIO
196
+ )
197
+ const bucketCounts = PcbScene3dModelBounds.#buildZBuckets(values)
198
+ const maxBucketCount =
199
+ PcbScene3dModelBounds.#resolveMaxBucketCount(bucketCounts)
200
+ const minCount =
201
+ maxBucketCount * PcbScene3dModelBounds.#SUPPORT_BUCKET_MIN_DENSITY
202
+ const candidates = [...bucketCounts.entries()]
203
+ .filter(([bucketZ, count]) => {
204
+ return (
205
+ bucketZ >
206
+ minZ +
207
+ PcbScene3dModelBounds.#LOWER_SUPPORT_MIN_GAP_MIL &&
208
+ bucketZ <= minZ + lowerWindow &&
209
+ count >= minCount
210
+ )
211
+ })
212
+ .sort((left, right) => right[1] - left[1] || left[0] - right[0])
213
+
214
+ return candidates.length ? candidates[0][0] : null
215
+ }
216
+
217
+ /**
218
+ * Resolves minimum and maximum Z without spreading large vertex arrays.
219
+ * @param {number[]} values Model-local transformed Z coordinates.
220
+ * @returns {{ minZ: number, maxZ: number }}
221
+ */
222
+ static #resolveZExtents(values) {
223
+ let minZ = Number.POSITIVE_INFINITY
224
+ let maxZ = Number.NEGATIVE_INFINITY
225
+
226
+ values.forEach((value) => {
227
+ minZ = Math.min(minZ, value)
228
+ maxZ = Math.max(maxZ, value)
229
+ })
230
+
231
+ return { minZ, maxZ }
232
+ }
233
+
234
+ /**
235
+ * Resolves the largest bucket count without spreading map values.
236
+ * @param {Map<number, number>} bucketCounts Z bucket counts.
237
+ * @returns {number}
238
+ */
239
+ static #resolveMaxBucketCount(bucketCounts) {
240
+ let maxCount = 0
241
+
242
+ bucketCounts.forEach((count) => {
243
+ maxCount = Math.max(maxCount, count)
244
+ })
245
+
246
+ return maxCount
247
+ }
248
+
249
+ /**
250
+ * Buckets transformed Z coordinates to make dense support planes robust to
251
+ * small floating point and tessellation differences.
252
+ * @param {number[]} values Model-local transformed Z coordinates.
253
+ * @returns {Map<number, number>}
254
+ */
255
+ static #buildZBuckets(values) {
256
+ const buckets = new Map()
257
+
258
+ values.forEach((value) => {
259
+ const bucket =
260
+ Math.round(value / PcbScene3dModelBounds.#SUPPORT_BUCKET_MIL) *
261
+ PcbScene3dModelBounds.#SUPPORT_BUCKET_MIL
262
+ buckets.set(bucket, (buckets.get(bucket) || 0) + 1)
263
+ })
264
+
265
+ return buckets
266
+ }
267
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Tracks external model placements that came from app-side model lookup.
3
+ */
4
+ export class PcbScene3dModelSearchPlacement {
5
+ /**
6
+ * Adds one loaded placement root when its model came from lookup.
7
+ * @param {any} placement External model placement.
8
+ * @param {any} rootObject Loaded model root.
9
+ * @param {Set<any>} roots Tracked lookup model roots.
10
+ * @returns {void}
11
+ */
12
+ static registerRoot(placement, rootObject, roots) {
13
+ if (
14
+ rootObject &&
15
+ roots &&
16
+ PcbScene3dModelSearchPlacement.isPlacement(placement)
17
+ ) {
18
+ roots.add(rootObject)
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Checks whether an external placement came from app-side model lookup.
24
+ * @param {{ source?: string, origin?: string, externalModel?: { source?: string, origin?: string } }} placement External model placement.
25
+ * @returns {boolean}
26
+ */
27
+ static isPlacement(placement) {
28
+ return [
29
+ placement?.source,
30
+ placement?.origin,
31
+ placement?.externalModel?.source,
32
+ placement?.externalModel?.origin
33
+ ].some(
34
+ (value) =>
35
+ String(value || '')
36
+ .trim()
37
+ .toLowerCase() === 'model-search'
38
+ )
39
+ }
40
+ }