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.
- package/docs/api.md +10 -0
- package/package.json +2 -2
- package/src/PcbScene3dAdjustmentControlBinder.mjs +667 -0
- package/src/PcbScene3dComponentAdjustment.mjs +174 -0
- package/src/PcbScene3dComponentAdjustmentRegistry.mjs +95 -0
- package/src/PcbScene3dController.mjs +214 -197
- package/src/PcbScene3dCopperFactory.mjs +4 -1
- package/src/PcbScene3dCutoutGeometryFilter.mjs +9 -3
- package/src/PcbScene3dExternalModels.mjs +56 -71
- package/src/PcbScene3dGeometryBoundsResolver.mjs +78 -0
- package/src/PcbScene3dModelBounds.mjs +267 -0
- package/src/PcbScene3dModelSearchPlacement.mjs +40 -0
- package/src/PcbScene3dPadFactory.mjs +221 -20
- package/src/PcbScene3dRenderGroupVisibility.mjs +23 -1
- package/src/PcbScene3dRuntime.mjs +62 -51
- package/src/PcbScene3dSelectionInspectorRenderer.mjs +581 -0
- package/src/PcbScene3dSelectionResolver.mjs +42 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +4 -1
- package/src/PcbScene3dText.mjs +3 -0
- package/src/styles/scene3d.css +142 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|