pcb-scene3d-viewer 1.1.21 → 1.1.31

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 (55) hide show
  1. package/README.md +3 -2
  2. package/docs/api.md +27 -1
  3. package/docs/circuitjson.md +63 -18
  4. package/docs/model-format.md +13 -2
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +2 -2
  7. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +25 -5
  8. package/src/PcbAssemblyComponentMeshBuilder.mjs +762 -0
  9. package/src/PcbAssemblyFillGeometryResolver.mjs +579 -0
  10. package/src/PcbAssemblyFillRingNormalizer.mjs +209 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +41 -301
  12. package/src/PcbAssemblyGltfModelMeshParser.mjs +912 -0
  13. package/src/PcbAssemblyGltfValidator.mjs +460 -0
  14. package/src/PcbAssemblyGltfWriter.mjs +801 -0
  15. package/src/PcbAssemblyMeshUtils.mjs +117 -0
  16. package/src/PcbAssemblyModelMeshLoader.mjs +18 -0
  17. package/src/PcbAssemblyPadMeshBuilder.mjs +394 -0
  18. package/src/PcbAssemblyStepWriter.mjs +24 -2
  19. package/src/PcbAssemblyTextModelMeshParser.mjs +602 -0
  20. package/src/PcbModelArchiveExporter.mjs +521 -7
  21. package/src/PcbScene3dCircuitJsonAdapter.mjs +409 -7
  22. package/src/PcbScene3dCircuitJsonModelTransform.mjs +232 -0
  23. package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +242 -0
  24. package/src/PcbScene3dComponentVisibility.mjs +100 -5
  25. package/src/PcbScene3dController.mjs +25 -55
  26. package/src/PcbScene3dCopperDetailFilter.mjs +86 -9
  27. package/src/PcbScene3dCopperDetailGroupBuilder.mjs +186 -15
  28. package/src/PcbScene3dCopperDrillCutoutBuilder.mjs +258 -0
  29. package/src/PcbScene3dCopperFactory.mjs +99 -85
  30. package/src/PcbScene3dCopperFillMeshBuilder.mjs +393 -0
  31. package/src/PcbScene3dCopperLayerFilter.mjs +32 -3
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +17 -14
  33. package/src/PcbScene3dExternalCompanionFallback.mjs +202 -0
  34. package/src/PcbScene3dExternalModelCenteringPolicy.mjs +21 -0
  35. package/src/PcbScene3dExternalModelOpacity.mjs +51 -0
  36. package/src/PcbScene3dExternalModelPlacementRepair.mjs +5 -2
  37. package/src/PcbScene3dExternalModelSourceOriginPolicy.mjs +41 -0
  38. package/src/PcbScene3dExternalModels.mjs +16 -7
  39. package/src/PcbScene3dFallbackBodyFactory.mjs +105 -0
  40. package/src/PcbScene3dFallbackVisibility.mjs +58 -1
  41. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +68 -0
  42. package/src/PcbScene3dPadFactory.mjs +35 -2
  43. package/src/PcbScene3dRenderGroupVisibility.mjs +8 -1
  44. package/src/PcbScene3dRuntime.mjs +94 -100
  45. package/src/PcbScene3dRuntimeHelpers.mjs +39 -0
  46. package/src/PcbScene3dSelectionIndexBuilder.mjs +87 -0
  47. package/src/PcbScene3dSelectionInspectorRenderer.mjs +50 -11
  48. package/src/PcbScene3dSelectionMarkerFactory.mjs +333 -0
  49. package/src/PcbScene3dSelectionMarkerOverlay.mjs +70 -0
  50. package/src/PcbScene3dSelectionStyler.mjs +52 -2
  51. package/src/PcbScene3dStaticBodyFactory.mjs +263 -0
  52. package/src/PcbScene3dText.mjs +1 -0
  53. package/src/PcbScene3dTransparentMeshSplitter.mjs +623 -0
  54. package/src/PcbScene3dViaFactory.mjs +27 -7
  55. package/src/scene3d.mjs +3 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Detects fallback bodies that should remain visible as companions for partial
3
+ * external model placements.
4
+ */
5
+ export class PcbScene3dExternalCompanionFallback {
6
+ /**
7
+ * Checks whether a component fallback body should remain visible as the
8
+ * package companion for a partial embedded external model.
9
+ * @param {{ externalPlacements?: object[], staticBodyPlacements?: object[] }} sceneDescription Scene data.
10
+ * @param {{ designator?: string, mountSide?: string, positionMil?: { x?: number, y?: number }, body?: { sizeMil?: { width?: number, depth?: number } } }} component Component scene entry.
11
+ * @returns {boolean}
12
+ */
13
+ static shouldKeepFallback(sceneDescription, component) {
14
+ const designator =
15
+ PcbScene3dExternalCompanionFallback.#normalizedDesignator(
16
+ component?.designator
17
+ )
18
+ if (!designator) {
19
+ return false
20
+ }
21
+
22
+ if (
23
+ PcbScene3dExternalCompanionFallback.#hasAuthoredCompanionBase(
24
+ sceneDescription,
25
+ component
26
+ )
27
+ ) {
28
+ return false
29
+ }
30
+
31
+ return PcbScene3dExternalCompanionFallback.#externalPlacements(
32
+ sceneDescription
33
+ ).some(
34
+ (placement) =>
35
+ PcbScene3dExternalCompanionFallback.#normalizedDesignator(
36
+ placement?.designator
37
+ ) === designator &&
38
+ PcbScene3dExternalCompanionFallback.#isPartialEmbeddedPlacement(
39
+ placement
40
+ )
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Checks whether an authored static body already provides the package base
46
+ * for one partial embedded placement.
47
+ * @param {{ staticBodyPlacements?: object[] }} sceneDescription Scene data.
48
+ * @param {{ mountSide?: string, positionMil?: { x?: number, y?: number }, body?: { sizeMil?: { width?: number, depth?: number } } }} component Component scene entry.
49
+ * @returns {boolean}
50
+ */
51
+ static #hasAuthoredCompanionBase(sceneDescription, component) {
52
+ return PcbScene3dExternalCompanionFallback.#staticBodyPlacements(
53
+ sceneDescription
54
+ ).some(
55
+ (placement) =>
56
+ PcbScene3dExternalCompanionFallback.#isSameSide(
57
+ placement,
58
+ component
59
+ ) &&
60
+ PcbScene3dExternalCompanionFallback.#isRenderableBase(
61
+ placement
62
+ ) &&
63
+ PcbScene3dExternalCompanionFallback.#isNearComponent(
64
+ placement,
65
+ component
66
+ )
67
+ )
68
+ }
69
+
70
+ /**
71
+ * Resolves normalized external placements from one scene description.
72
+ * @param {{ externalPlacements?: object[] }} sceneDescription Scene data.
73
+ * @returns {object[]}
74
+ */
75
+ static #externalPlacements(sceneDescription) {
76
+ return Array.isArray(sceneDescription?.externalPlacements)
77
+ ? sceneDescription.externalPlacements
78
+ : []
79
+ }
80
+
81
+ /**
82
+ * Resolves normalized static body placements from one scene description.
83
+ * @param {{ staticBodyPlacements?: object[] }} sceneDescription Scene data.
84
+ * @returns {object[]}
85
+ */
86
+ static #staticBodyPlacements(sceneDescription) {
87
+ return Array.isArray(sceneDescription?.staticBodyPlacements)
88
+ ? sceneDescription.staticBodyPlacements
89
+ : []
90
+ }
91
+
92
+ /**
93
+ * Checks whether a placement and component share a board side.
94
+ * @param {object} placement Static body placement.
95
+ * @param {object} component Scene component.
96
+ * @returns {boolean}
97
+ */
98
+ static #isSameSide(placement, component) {
99
+ return (
100
+ String(placement?.mountSide || 'top').toLowerCase() ===
101
+ String(component?.mountSide || 'top').toLowerCase()
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Checks whether one static body can act as a package base.
107
+ * @param {object} placement Static body placement.
108
+ * @returns {boolean}
109
+ */
110
+ static #isRenderableBase(placement) {
111
+ const geometry = placement?.geometry || {}
112
+ return (
113
+ String(geometry?.kind || '').toLowerCase() === 'extruded-polygon' &&
114
+ PcbScene3dExternalCompanionFallback.#geometrySpan(geometry).width >
115
+ 0 &&
116
+ PcbScene3dExternalCompanionFallback.#geometrySpan(geometry).depth >
117
+ 0
118
+ )
119
+ }
120
+
121
+ /**
122
+ * Checks whether one authored base is close enough to own a component base.
123
+ * @param {object} placement Static body placement.
124
+ * @param {object} component Scene component.
125
+ * @returns {boolean}
126
+ */
127
+ static #isNearComponent(placement, component) {
128
+ const span = PcbScene3dExternalCompanionFallback.#geometrySpan(
129
+ placement?.geometry
130
+ )
131
+ const componentSize = component?.body?.sizeMil || {}
132
+ const threshold = Math.max(
133
+ 30,
134
+ Number(span.width || 0) / 2,
135
+ Number(span.depth || 0) / 2,
136
+ Number(componentSize.width || 0) / 2,
137
+ Number(componentSize.depth || 0) / 2
138
+ )
139
+ const dx =
140
+ Number(placement?.positionMil?.x || 0) -
141
+ Number(component?.positionMil?.x || 0)
142
+ const dy =
143
+ Number(placement?.positionMil?.y || 0) -
144
+ Number(component?.positionMil?.y || 0)
145
+
146
+ return Math.hypot(dx, dy) <= threshold
147
+ }
148
+
149
+ /**
150
+ * Resolves width and depth for one polygon geometry.
151
+ * @param {{ verticesMil?: { x?: number, y?: number }[] } | undefined} geometry Static geometry.
152
+ * @returns {{ width: number, depth: number }}
153
+ */
154
+ static #geometrySpan(geometry) {
155
+ const vertices = Array.isArray(geometry?.verticesMil)
156
+ ? geometry.verticesMil
157
+ : []
158
+ if (vertices.length < 3) {
159
+ return { width: 0, depth: 0 }
160
+ }
161
+
162
+ const xs = vertices.map((vertex) => Number(vertex?.x || 0))
163
+ const ys = vertices.map((vertex) => Number(vertex?.y || 0))
164
+
165
+ return {
166
+ width: Math.max(...xs) - Math.min(...xs),
167
+ depth: Math.max(...ys) - Math.min(...ys)
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Checks for embedded external placements that represent a sub-body, not a
173
+ * complete replacement for the procedural component body.
174
+ * @param {object} placement External model placement.
175
+ * @returns {boolean}
176
+ */
177
+ static #isPartialEmbeddedPlacement(placement) {
178
+ const projection = placement?.projection || {}
179
+ const bounds = projection?.boundsMil || {}
180
+
181
+ return (
182
+ String(placement?.externalModel?.origin || '').toLowerCase() ===
183
+ 'embedded' &&
184
+ String(projection?.source || '').toLowerCase() ===
185
+ 'model-anchor-fallback' &&
186
+ Math.max(
187
+ Number(bounds.width || 0),
188
+ Number(bounds.depth || 0),
189
+ Number(bounds.height || 0)
190
+ ) <= 0
191
+ )
192
+ }
193
+
194
+ /**
195
+ * Normalizes one designator for component/external placement matching.
196
+ * @param {unknown} designator Candidate designator.
197
+ * @returns {string}
198
+ */
199
+ static #normalizedDesignator(designator) {
200
+ return String(designator || '').trim()
201
+ }
202
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Resolves which external-model placements can be centered after loading.
3
+ */
4
+ export class PcbScene3dExternalModelCenteringPolicy {
5
+ /**
6
+ * Checks whether a loaded model should be centered on its owner footprint.
7
+ * @param {object | null | undefined} placement External placement.
8
+ * @returns {boolean}
9
+ */
10
+ static shouldCenterOnOwner(placement) {
11
+ const source = String(placement?.projection?.source || '').toLowerCase()
12
+ if (source === 'pad-fallback') {
13
+ return true
14
+ }
15
+
16
+ return (
17
+ source === 'model-anchor-fallback' &&
18
+ Boolean(placement?.modelTransform?.ownerAnchorOffsetMil)
19
+ )
20
+ }
21
+ }
@@ -0,0 +1,51 @@
1
+ import { PcbScene3dTransparentMeshSplitter } from './PcbScene3dTransparentMeshSplitter.mjs'
2
+
3
+ /**
4
+ * Applies source-authored display opacity to loaded external model materials.
5
+ */
6
+ export class PcbScene3dExternalModelOpacity {
7
+ /**
8
+ * Applies source-authored placement opacity to every material below a
9
+ * loaded external model.
10
+ * @param {any} THREE Three.js namespace.
11
+ * @param {{ bodyOpacity?: number | string } | null | undefined} placement Placement metadata.
12
+ * @param {any} placementGroup Rendered placement root.
13
+ * @returns {void}
14
+ */
15
+ static apply(THREE, placement, placementGroup) {
16
+ const opacity = Number(placement?.bodyOpacity)
17
+ if (!Number.isFinite(opacity) || opacity < 0 || opacity >= 1) {
18
+ return
19
+ }
20
+
21
+ const clampedOpacity = Math.max(0, Math.min(1, opacity))
22
+ PcbScene3dExternalModelOpacity.#visitMaterials(
23
+ placementGroup,
24
+ (material) => {
25
+ material.transparent = true
26
+ material.opacity = clampedOpacity
27
+ material.depthWrite = false
28
+ material.needsUpdate = true
29
+ }
30
+ )
31
+ PcbScene3dTransparentMeshSplitter.split(THREE, placementGroup)
32
+ }
33
+
34
+ /**
35
+ * Visits material instances below one object tree.
36
+ * @param {any} rootObject Root object.
37
+ * @param {(material: any) => void} visitor Material visitor.
38
+ * @returns {void}
39
+ */
40
+ static #visitMaterials(rootObject, visitor) {
41
+ rootObject?.traverse?.((object) => {
42
+ const materials = Array.isArray(object?.material)
43
+ ? object.material
44
+ : object?.material
45
+ ? [object.material]
46
+ : []
47
+
48
+ materials.filter(Boolean).forEach((material) => visitor(material))
49
+ })
50
+ }
51
+ }
@@ -1,3 +1,5 @@
1
+ import { PcbScene3dExternalModelCenteringPolicy } from './PcbScene3dExternalModelCenteringPolicy.mjs'
2
+
1
3
  /**
2
4
  * Repairs loaded external-model placements using normalized scene metadata.
3
5
  */
@@ -352,8 +354,9 @@ export class PcbScene3dExternalModelPlacementRepair {
352
354
  if (
353
355
  !component ||
354
356
  !placementGroup?.position ||
355
- String(placement?.projection?.source || '').toLowerCase() !==
356
- 'pad-fallback' ||
357
+ !PcbScene3dExternalModelCenteringPolicy.shouldCenterOnOwner(
358
+ placement
359
+ ) ||
357
360
  !THREE?.Box3
358
361
  ) {
359
362
  return
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Resolves source-origin adjustment policy for external model placements.
3
+ */
4
+ export class PcbScene3dExternalModelSourceOriginPolicy {
5
+ /**
6
+ * Checks whether an explicit owner anchor should keep source-origin repair
7
+ * disabled. Translucent cover-sized bodies use corner-origin STEP data, so
8
+ * they still need the embedded source-origin correction.
9
+ * @param {{ bodyOpacity?: number | string, modelTransform?: { ownerAnchorOffsetMil?: object }, projection?: { boundsMil?: { width?: number, depth?: number } } }} placement Placement metadata.
10
+ * @returns {boolean}
11
+ */
12
+ static shouldSkipOwnerAnchoredAdjustment(placement) {
13
+ return (
14
+ Boolean(placement?.modelTransform?.ownerAnchorOffsetMil) &&
15
+ !PcbScene3dExternalModelSourceOriginPolicy.#isTransparentCoverSizedPlacement(
16
+ placement
17
+ )
18
+ )
19
+ }
20
+
21
+ /**
22
+ * Checks whether a placement looks like an authored translucent cover body.
23
+ * @param {{ bodyOpacity?: number | string, projection?: { boundsMil?: { width?: number, depth?: number } } }} placement Placement metadata.
24
+ * @returns {boolean}
25
+ */
26
+ static #isTransparentCoverSizedPlacement(placement) {
27
+ const opacity = Number(placement?.bodyOpacity)
28
+ const bounds = placement?.projection?.boundsMil || {}
29
+ const width = Number(bounds.width)
30
+ const depth = Number(bounds.depth)
31
+
32
+ return (
33
+ Number.isFinite(opacity) &&
34
+ opacity >= 0 &&
35
+ opacity < 1 &&
36
+ Number.isFinite(width) &&
37
+ Number.isFinite(depth) &&
38
+ Math.min(Math.abs(width), Math.abs(depth)) >= 200
39
+ )
40
+ }
41
+ }
@@ -2,8 +2,10 @@ import { PcbScene3dBoardAssemblyPresentation } from './PcbScene3dBoardAssemblyPr
2
2
  import { PcbScene3dBoardAssemblyPlacement } from './PcbScene3dBoardAssemblyPlacement.mjs'
3
3
  import { PcbScene3dBoardAssemblyTransform } from './PcbScene3dBoardAssemblyTransform.mjs'
4
4
  import { PcbScene3dBufferAttributeFactory } from './PcbScene3dBufferAttributeFactory.mjs'
5
+ import { PcbScene3dExternalModelOpacity } from './PcbScene3dExternalModelOpacity.mjs'
5
6
  import { PcbScene3dExternalModelPlacementRepair } from './PcbScene3dExternalModelPlacementRepair.mjs'
6
7
  import { PcbScene3dExternalModelLoadOrder } from './PcbScene3dExternalModelLoadOrder.mjs'
8
+ import { PcbScene3dExternalModelSourceOriginPolicy } from './PcbScene3dExternalModelSourceOriginPolicy.mjs'
7
9
  import { PcbScene3dModelBounds } from './PcbScene3dModelBounds.mjs'
8
10
  import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
9
11
  import { PcbScene3dStepLoader } from './PcbScene3dStepLoader.mjs'
@@ -54,7 +56,6 @@ export class PcbScene3dExternalModels {
54
56
  if (!loadedGroup || options?.isDisposed?.()) {
55
57
  continue
56
58
  }
57
-
58
59
  externalModelsGroup.add(loadedGroup)
59
60
  PcbScene3dExternalModelPlacementRepair.apply(
60
61
  options.three,
@@ -65,6 +66,11 @@ export class PcbScene3dExternalModels {
65
66
  PcbScene3dExternalModelPlacementRepair.isolatePlacementMaterials(
66
67
  loadedGroup
67
68
  )
69
+ PcbScene3dExternalModelOpacity.apply(
70
+ options.three,
71
+ placement,
72
+ loadedGroup
73
+ )
68
74
  options?.onPlacementGroup?.(placement, loadedGroup)
69
75
  processedPlacements += 1
70
76
  if (processedPlacements % 12 === 0) {
@@ -293,6 +299,12 @@ export class PcbScene3dExternalModels {
293
299
  designator,
294
300
  sourceType: 'external-model'
295
301
  }
302
+ const variantGroupKey = String(
303
+ placement?.coLocatedVariantGroupKey || ''
304
+ ).trim()
305
+ if (variantGroupKey) {
306
+ wrapperGroup.userData.scene3dVariantGroupKey = variantGroupKey
307
+ }
296
308
  adjustmentGroup.userData.scene3dAdjustmentTarget = true
297
309
  adjustmentGroup.userData.scene3dAdjustmentDesignator = designator
298
310
  adjustmentGroup.userData.scene3dAdjustmentBaseline =
@@ -497,6 +509,9 @@ export class PcbScene3dExternalModels {
497
509
  if (
498
510
  String(placement?.externalModel?.origin || '').toLowerCase() !==
499
511
  'embedded' ||
512
+ PcbScene3dExternalModelSourceOriginPolicy.shouldSkipOwnerAnchoredAdjustment(
513
+ placement
514
+ ) ||
500
515
  PcbScene3dExternalModels.#normalizeAngle(modelRotation?.x) !==
501
516
  270 ||
502
517
  !Number.isFinite(centerX) ||
@@ -917,15 +932,12 @@ export class PcbScene3dExternalModels {
917
932
  const triangleCount = Math.floor(Number(indices?.length || 0) / 3)
918
933
  let triangleIndex = 0
919
934
  let faceColorIndex = 0
920
-
921
935
  while (triangleIndex < triangleCount) {
922
936
  const firstIndex = triangleIndex
923
937
  let lastIndex = triangleCount
924
938
  let materialIndex = 0
925
-
926
939
  if (faceColorIndex < faceColors.length) {
927
940
  const currentFaceColor = faceColors[faceColorIndex]
928
-
929
941
  if (triangleIndex < currentFaceColor.first) {
930
942
  lastIndex = currentFaceColor.first
931
943
  } else {
@@ -937,7 +949,6 @@ export class PcbScene3dExternalModels {
937
949
  faceColorIndex += 1
938
950
  }
939
951
  }
940
-
941
952
  geometry.addGroup(
942
953
  firstIndex * 3,
943
954
  Math.max(lastIndex - firstIndex, 0) * 3,
@@ -957,7 +968,6 @@ export class PcbScene3dExternalModels {
957
968
  const first = Number(faceColor?.first)
958
969
  const last = Number(faceColor?.last)
959
970
  const triangleCount = Math.floor(Number(indices?.length || 0) / 3)
960
-
961
971
  return (
962
972
  Number.isInteger(first) &&
963
973
  Number.isInteger(last) &&
@@ -977,7 +987,6 @@ export class PcbScene3dExternalModels {
977
987
  if (!Array.isArray(color) || color.length < 3) {
978
988
  return 0xc8c8c8
979
989
  }
980
-
981
990
  return new THREE.Color(
982
991
  Number(color[0] || 0),
983
992
  Number(color[1] || 0),
@@ -0,0 +1,105 @@
1
+ import { PcbScene3dBodyColor } from './PcbScene3dBodyColor.mjs'
2
+ import { PcbScene3dComponentAdjustment } from './PcbScene3dComponentAdjustment.mjs'
3
+ import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
4
+
5
+ /**
6
+ * Builds procedural fallback body render roots for PCB components.
7
+ */
8
+ export class PcbScene3dFallbackBodyFactory {
9
+ /**
10
+ * Builds one procedural fallback body root.
11
+ * @param {any} THREE Three.js namespace.
12
+ * @param {{ designator?: string, positionMil: { x: number, y: number, z: number }, rotationDeg: number, mountSide: string, body: { family: string, sizeMil: { width: number, depth: number, height: number } } }} component Component scene entry.
13
+ * @param {{ companionBase?: boolean }} [options] Rendering options.
14
+ * @returns {{ rootGroup: any, adjustmentGroup: any }}
15
+ */
16
+ static build(THREE, component, options = {}) {
17
+ const family = component.body.family
18
+ const size = component.body.sizeMil
19
+ const material = new THREE.MeshStandardMaterial({
20
+ color: PcbScene3dFallbackBodyFactory.#resolveColor(family, options),
21
+ roughness: 0.72,
22
+ metalness: family === 'chip' ? 0.12 : 0.08
23
+ })
24
+ const mesh = PcbScene3dFallbackBodyFactory.#buildMesh(
25
+ THREE,
26
+ family,
27
+ size,
28
+ material
29
+ )
30
+ const mountRig = PcbScene3dMountRig.create(THREE, component)
31
+ const adjustmentGroup =
32
+ PcbScene3dFallbackBodyFactory.#buildAdjustmentGroup(
33
+ THREE,
34
+ component
35
+ )
36
+
37
+ mountRig.rootGroup.userData.scene3dSelection = {
38
+ designator: String(component?.designator || 'component'),
39
+ sourceType: 'component'
40
+ }
41
+ adjustmentGroup.add(mesh)
42
+ mountRig.faceGroup.add(adjustmentGroup)
43
+
44
+ return {
45
+ rootGroup: mountRig.rootGroup,
46
+ adjustmentGroup
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Resolves the fallback body color.
52
+ * @param {string} family Package family.
53
+ * @param {{ companionBase?: boolean }} options Rendering options.
54
+ * @returns {number}
55
+ */
56
+ static #resolveColor(family, options) {
57
+ return options?.companionBase
58
+ ? 0x808080
59
+ : PcbScene3dBodyColor.resolve(family)
60
+ }
61
+
62
+ /**
63
+ * Builds the body mesh for one package family.
64
+ * @param {any} THREE Three.js namespace.
65
+ * @param {string} family Package family.
66
+ * @param {{ width: number, depth: number, height: number }} size Body size.
67
+ * @param {any} material Mesh material.
68
+ * @returns {any}
69
+ */
70
+ static #buildMesh(THREE, family, size, material) {
71
+ if (family === 'radial-capacitor' || family === 'test-point') {
72
+ return new THREE.Mesh(
73
+ new THREE.CylinderGeometry(
74
+ size.width / 2,
75
+ size.width / 2,
76
+ size.height,
77
+ 28
78
+ ),
79
+ material
80
+ )
81
+ }
82
+
83
+ return new THREE.Mesh(
84
+ new THREE.BoxGeometry(size.width, size.depth, size.height),
85
+ material
86
+ )
87
+ }
88
+
89
+ /**
90
+ * Builds the transform node used by live component adjustments.
91
+ * @param {any} THREE Three.js namespace.
92
+ * @param {{ designator?: string }} component Component scene entry.
93
+ * @returns {any}
94
+ */
95
+ static #buildAdjustmentGroup(THREE, component) {
96
+ const adjustmentGroup = new THREE.Group()
97
+ adjustmentGroup.userData.scene3dAdjustmentTarget = true
98
+ adjustmentGroup.userData.scene3dAdjustmentDesignator = String(
99
+ component?.designator || 'component'
100
+ )
101
+ adjustmentGroup.userData.scene3dAdjustmentBaseline =
102
+ PcbScene3dComponentAdjustment.neutral()
103
+ return adjustmentGroup
104
+ }
105
+ }
@@ -74,9 +74,66 @@ export class PcbScene3dFallbackVisibility {
74
74
  return
75
75
  }
76
76
 
77
+ const stitchedCompanion =
78
+ PcbScene3dFallbackVisibility.shouldKeepExternalCompanion(
79
+ rootObject,
80
+ toggles
81
+ )
77
82
  rootObject.visible =
78
- showFallbackBodies && !hideForLoadedExternal
83
+ stitchedCompanion ||
84
+ (showFallbackBodies && !hideForLoadedExternal)
79
85
  })
80
86
  })
81
87
  }
88
+
89
+ /**
90
+ * Checks whether any fallback root should keep the fallback group visible
91
+ * as part of a stitched external-model package.
92
+ * @param {Map<string, Set<any>>} fallbackRoots Fallback root registry.
93
+ * @param {{ 'external-models'?: boolean }} toggles Detail toggles.
94
+ * @returns {boolean}
95
+ */
96
+ static hasVisibleExternalCompanion(fallbackRoots, toggles) {
97
+ if (
98
+ !(fallbackRoots instanceof Map) ||
99
+ !Boolean(toggles?.['external-models'])
100
+ ) {
101
+ return false
102
+ }
103
+
104
+ let hasCompanion = false
105
+ fallbackRoots.forEach((roots) => {
106
+ roots?.forEach?.((rootObject) => {
107
+ hasCompanion =
108
+ hasCompanion ||
109
+ PcbScene3dFallbackVisibility.#isExternalCompanion(
110
+ rootObject
111
+ )
112
+ })
113
+ })
114
+ return hasCompanion
115
+ }
116
+
117
+ /**
118
+ * Checks whether one fallback body should stay visible as a stitched
119
+ * external-model companion under the active toggles.
120
+ * @param {any} rootObject Fallback root object.
121
+ * @param {{ 'external-models'?: boolean }} toggles Detail toggles.
122
+ * @returns {boolean}
123
+ */
124
+ static shouldKeepExternalCompanion(rootObject, toggles) {
125
+ return (
126
+ Boolean(toggles?.['external-models']) &&
127
+ PcbScene3dFallbackVisibility.#isExternalCompanion(rootObject)
128
+ )
129
+ }
130
+
131
+ /**
132
+ * Checks whether a fallback body is part of a stitched external model.
133
+ * @param {any} rootObject Fallback root object.
134
+ * @returns {boolean}
135
+ */
136
+ static #isExternalCompanion(rootObject) {
137
+ return Boolean(rootObject?.userData?.scene3dFallbackExternalCompanion)
138
+ }
82
139
  }
@@ -0,0 +1,68 @@
1
+ import { PcbScene3dGeometryZCompressor } from './PcbScene3dGeometryZCompressor.mjs'
2
+
3
+ /**
4
+ * Builds side-specific copper relief groups that sit below solder mask.
5
+ */
6
+ export class PcbScene3dMaskCoveredCopperSideGroupBuilder {
7
+ /**
8
+ * Builds one side group from prepared mask-covered copper meshes.
9
+ * @param {any} THREE Three.js namespace.
10
+ * @param {{ trackMesh?: any | null, arcMesh?: any | null, fillMesh?: any | null, z?: number, mirrorY?: boolean }} options
11
+ * @returns {any}
12
+ */
13
+ static build(
14
+ THREE,
15
+ {
16
+ trackMesh = null,
17
+ arcMesh = null,
18
+ fillMesh = null,
19
+ z = 0,
20
+ mirrorY = false
21
+ } = {}
22
+ ) {
23
+ const group = new THREE.Group()
24
+
25
+ PcbScene3dMaskCoveredCopperSideGroupBuilder.#addCompressedMesh(
26
+ group,
27
+ trackMesh,
28
+ 'mask-covered-copper-tracks',
29
+ z
30
+ )
31
+ PcbScene3dMaskCoveredCopperSideGroupBuilder.#addCompressedMesh(
32
+ group,
33
+ arcMesh,
34
+ 'mask-covered-copper-arcs',
35
+ z
36
+ )
37
+ PcbScene3dMaskCoveredCopperSideGroupBuilder.#addCompressedMesh(
38
+ group,
39
+ fillMesh,
40
+ 'mask-covered-copper-fills',
41
+ z
42
+ )
43
+
44
+ if (mirrorY && group.children.length) {
45
+ group.rotation.x = Math.PI
46
+ }
47
+
48
+ return group
49
+ }
50
+
51
+ /**
52
+ * Compresses one mesh into mask relief space before adding it to a group.
53
+ * @param {any} group Parent group.
54
+ * @param {any | null} mesh Mesh to add.
55
+ * @param {string} name Scene object name.
56
+ * @param {number} z Source center Z.
57
+ * @returns {void}
58
+ */
59
+ static #addCompressedMesh(group, mesh, name, z) {
60
+ if (!mesh) {
61
+ return
62
+ }
63
+
64
+ PcbScene3dGeometryZCompressor.compressMaskCoveredCopperMesh(mesh, z)
65
+ mesh.name = name
66
+ group.add(mesh)
67
+ }
68
+ }