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,232 @@
1
+ import { CircuitJsonUnits } from 'circuitjson-toolkit'
2
+
3
+ /**
4
+ * Normalizes CAD component model placement hints from CircuitJSON.
5
+ */
6
+ export class PcbScene3dCircuitJsonModelTransform {
7
+ /**
8
+ * Builds optional model-local transforms.
9
+ * @param {object} cadComponent CAD component element.
10
+ * @returns {object | null}
11
+ */
12
+ static build(cadComponent) {
13
+ const offset = cadComponent?.model_offset || cadComponent?.offset
14
+ const rotation =
15
+ cadComponent?.model_rotation || cadComponent?.model_rotation_deg
16
+ const rawScale = cadComponent?.model_scale
17
+ const unitScale = PcbScene3dCircuitJsonModelTransform.#positiveFinite(
18
+ cadComponent?.model_unit_to_mm_scale_factor,
19
+ 1
20
+ )
21
+ const originPositionMil =
22
+ PcbScene3dCircuitJsonModelTransform.#pointMmToMil(
23
+ cadComponent?.model_origin_position
24
+ )
25
+ const originAlignment =
26
+ PcbScene3dCircuitJsonModelTransform.#stringValue(
27
+ cadComponent?.model_origin_alignment
28
+ )
29
+ const objectFit = PcbScene3dCircuitJsonModelTransform.#stringValue(
30
+ cadComponent?.model_object_fit
31
+ )
32
+ const boardNormalDirection =
33
+ PcbScene3dCircuitJsonModelTransform.#stringValue(
34
+ cadComponent?.model_board_normal_direction
35
+ )
36
+ const targetSizeMil = PcbScene3dCircuitJsonModelTransform.#sizeMmToMil(
37
+ cadComponent?.model_size || cadComponent?.size
38
+ )
39
+
40
+ if (
41
+ !offset &&
42
+ !rotation &&
43
+ rawScale === undefined &&
44
+ unitScale === 1 &&
45
+ !originPositionMil &&
46
+ !originAlignment &&
47
+ !objectFit &&
48
+ !boardNormalDirection &&
49
+ !targetSizeMil
50
+ ) {
51
+ return null
52
+ }
53
+
54
+ return {
55
+ offsetMil: PcbScene3dCircuitJsonModelTransform.#offsetMil(offset),
56
+ rotationDeg:
57
+ PcbScene3dCircuitJsonModelTransform.#rotationDeg(rotation),
58
+ scale: PcbScene3dCircuitJsonModelTransform.#modelScale(
59
+ rawScale,
60
+ unitScale
61
+ ),
62
+ ...PcbScene3dCircuitJsonModelTransform.#optionalFields({
63
+ originPositionMil,
64
+ originAlignment,
65
+ objectFit,
66
+ boardNormalDirection,
67
+ targetSizeMil
68
+ })
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Builds display metadata for a CAD component placement.
74
+ * @param {object} cadComponent CAD component element.
75
+ * @returns {{ bodyOpacity?: number }}
76
+ */
77
+ static displayMetadata(cadComponent) {
78
+ const opacity =
79
+ PcbScene3dCircuitJsonModelTransform.#displayOpacity(cadComponent)
80
+ return opacity === null ? {} : { bodyOpacity: opacity }
81
+ }
82
+
83
+ /**
84
+ * Builds optional transform fields from populated metadata.
85
+ * @param {object} fields Candidate optional fields.
86
+ * @returns {object}
87
+ */
88
+ static #optionalFields(fields) {
89
+ return Object.fromEntries(
90
+ Object.entries(fields).filter((entry) => entry[1] !== null)
91
+ )
92
+ }
93
+
94
+ /**
95
+ * Resolves model-local offset fields in mils.
96
+ * @param {object | undefined} offset Offset source in millimeters.
97
+ * @returns {{ x: number, y: number, z: number }}
98
+ */
99
+ static #offsetMil(offset) {
100
+ return {
101
+ x: CircuitJsonUnits.mmToMil(offset?.x, 0),
102
+ y: CircuitJsonUnits.mmToMil(offset?.y, 0),
103
+ z: CircuitJsonUnits.mmToMil(offset?.z, 0)
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Resolves model-local rotation fields.
109
+ * @param {object | number | undefined} rotation Rotation metadata.
110
+ * @returns {{ x: number, y: number, z: number }}
111
+ */
112
+ static #rotationDeg(rotation) {
113
+ if (typeof rotation === 'number') {
114
+ return { x: 0, y: 0, z: Number(rotation || 0) }
115
+ }
116
+
117
+ return {
118
+ x: Number(rotation?.x || 0),
119
+ y: Number(rotation?.y || 0),
120
+ z: Number(rotation?.z || 0)
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Normalizes model scale fields and unit scale.
126
+ * @param {unknown} scale Scale metadata.
127
+ * @param {number} unitScale Unit-to-millimeter scale factor.
128
+ * @returns {{ x: number, y: number, z: number }}
129
+ */
130
+ static #modelScale(scale, unitScale) {
131
+ if (typeof scale === 'number') {
132
+ const value = Number(scale) || 1
133
+ return {
134
+ x: value * unitScale,
135
+ y: value * unitScale,
136
+ z: value * unitScale
137
+ }
138
+ }
139
+
140
+ return {
141
+ x: (Number(scale?.x ?? 1) || 1) * unitScale,
142
+ y: (Number(scale?.y ?? 1) || 1) * unitScale,
143
+ z: (Number(scale?.z ?? 1) || 1) * unitScale
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Converts an optional point from millimeters to mils.
149
+ * @param {object | undefined} point Point metadata.
150
+ * @returns {{ x: number, y: number, z: number } | null}
151
+ */
152
+ static #pointMmToMil(point) {
153
+ if (
154
+ !Number.isFinite(Number(point?.x)) &&
155
+ !Number.isFinite(Number(point?.y)) &&
156
+ !Number.isFinite(Number(point?.z))
157
+ ) {
158
+ return null
159
+ }
160
+
161
+ return {
162
+ x: CircuitJsonUnits.mmToMil(point?.x, 0),
163
+ y: CircuitJsonUnits.mmToMil(point?.y, 0),
164
+ z: CircuitJsonUnits.mmToMil(point?.z, 0)
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Converts optional model target size from millimeters to mils.
170
+ * @param {object | undefined} size Size metadata.
171
+ * @returns {{ x: number, y: number, z: number } | null}
172
+ */
173
+ static #sizeMmToMil(size) {
174
+ const x = size?.x ?? size?.width
175
+ const y = size?.y ?? size?.height
176
+ const z = size?.z ?? size?.depth
177
+ if (
178
+ !Number.isFinite(Number(x)) &&
179
+ !Number.isFinite(Number(y)) &&
180
+ !Number.isFinite(Number(z))
181
+ ) {
182
+ return null
183
+ }
184
+
185
+ return {
186
+ x: CircuitJsonUnits.mmToMil(x, 0),
187
+ y: CircuitJsonUnits.mmToMil(y, 0),
188
+ z: CircuitJsonUnits.mmToMil(z, 0)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Resolves optional display opacity.
194
+ * @param {object} cadComponent CAD component element.
195
+ * @returns {number | null}
196
+ */
197
+ static #displayOpacity(cadComponent) {
198
+ for (const value of [
199
+ cadComponent?.bodyOpacity,
200
+ cadComponent?.body_opacity,
201
+ cadComponent?.model_opacity
202
+ ]) {
203
+ const opacity = Number(value)
204
+ if (Number.isFinite(opacity) && opacity > 0 && opacity < 1) {
205
+ return opacity
206
+ }
207
+ }
208
+
209
+ return cadComponent?.show_as_translucent_model === true ? 0.5 : null
210
+ }
211
+
212
+ /**
213
+ * Returns a non-empty string or null.
214
+ * @param {unknown} value Candidate string value.
215
+ * @returns {string | null}
216
+ */
217
+ static #stringValue(value) {
218
+ const text = String(value || '').trim()
219
+ return text ? text : null
220
+ }
221
+
222
+ /**
223
+ * Returns a positive finite number or fallback.
224
+ * @param {unknown} value Candidate value.
225
+ * @param {number} fallback Fallback value.
226
+ * @returns {number}
227
+ */
228
+ static #positiveFinite(value, fallback) {
229
+ const number = Number(value)
230
+ return Number.isFinite(number) && number > 0 ? number : fallback
231
+ }
232
+ }
@@ -0,0 +1,242 @@
1
+ import { PcbScene3dExternalCompanionFallback } from './PcbScene3dExternalCompanionFallback.mjs'
2
+
3
+ /**
4
+ * Lifts authored model placements that sit on companion fallback bases.
5
+ */
6
+ export class PcbScene3dCompanionBasePlacementAdjuster {
7
+ static #ENVELOPE_MARGIN_RATIO = 0.25
8
+ static #ENVELOPE_MIN_MARGIN_MIL = 20
9
+ static #POSITION_EPSILON = 1e-6
10
+
11
+ /**
12
+ * Returns a scene copy with placements seated on companion fallback bases.
13
+ * @param {object} sceneDescription Scene description.
14
+ * @returns {object}
15
+ */
16
+ static adjust(sceneDescription) {
17
+ const bases =
18
+ PcbScene3dCompanionBasePlacementAdjuster.#companionBases(
19
+ sceneDescription
20
+ )
21
+ if (!bases.length) {
22
+ return sceneDescription
23
+ }
24
+
25
+ return {
26
+ ...sceneDescription,
27
+ externalPlacements:
28
+ PcbScene3dCompanionBasePlacementAdjuster.#adjustPlacements(
29
+ sceneDescription?.externalPlacements,
30
+ bases
31
+ ),
32
+ staticBodyPlacements:
33
+ PcbScene3dCompanionBasePlacementAdjuster.#adjustPlacements(
34
+ sceneDescription?.staticBodyPlacements,
35
+ bases
36
+ )
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Resolves companion fallback bases from components in one scene.
42
+ * @param {object} sceneDescription Scene description.
43
+ * @returns {object[]}
44
+ */
45
+ static #companionBases(sceneDescription) {
46
+ return (
47
+ Array.isArray(sceneDescription?.components)
48
+ ? sceneDescription.components
49
+ : []
50
+ )
51
+ .filter((component) =>
52
+ PcbScene3dExternalCompanionFallback.shouldKeepFallback(
53
+ sceneDescription,
54
+ component
55
+ )
56
+ )
57
+ .map((component) =>
58
+ PcbScene3dCompanionBasePlacementAdjuster.#buildBase(component)
59
+ )
60
+ .filter(Boolean)
61
+ }
62
+
63
+ /**
64
+ * Builds one companion base descriptor from a component.
65
+ * @param {object} component Scene component.
66
+ * @returns {object | null}
67
+ */
68
+ static #buildBase(component) {
69
+ const size = component?.body?.sizeMil || {}
70
+ const heightMil = Number(size.height || 0)
71
+ const widthMil = Number(size.width || 0)
72
+ const depthMil = Number(size.depth || 0)
73
+ if (!(heightMil > 0) || !(widthMil > 0) || !(depthMil > 0)) {
74
+ return null
75
+ }
76
+
77
+ return {
78
+ designator: String(component?.designator || '').trim(),
79
+ mountSide: String(component?.mountSide || 'top').toLowerCase(),
80
+ positionMil: {
81
+ x: Number(component?.positionMil?.x || 0),
82
+ y: Number(component?.positionMil?.y || 0),
83
+ z: Number(component?.positionMil?.z || 0)
84
+ },
85
+ rotationDeg: Number(component?.rotationDeg || 0),
86
+ widthMil,
87
+ depthMil,
88
+ heightMil,
89
+ signedLiftMil:
90
+ (PcbScene3dCompanionBasePlacementAdjuster.#isBottomSide(
91
+ component?.mountSide
92
+ )
93
+ ? -1
94
+ : 1) * heightMil
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Adjusts one placement list against all companion bases.
100
+ * @param {object[] | undefined} placements Original placements.
101
+ * @param {object[]} bases Companion bases.
102
+ * @returns {object[]}
103
+ */
104
+ static #adjustPlacements(placements, bases) {
105
+ return (Array.isArray(placements) ? placements : []).map((placement) =>
106
+ PcbScene3dCompanionBasePlacementAdjuster.#adjustPlacement(
107
+ placement,
108
+ bases
109
+ )
110
+ )
111
+ }
112
+
113
+ /**
114
+ * Adjusts one placement when it overlaps a companion base.
115
+ * @param {object} placement Original placement.
116
+ * @param {object[]} bases Companion bases.
117
+ * @returns {object}
118
+ */
119
+ static #adjustPlacement(placement, bases) {
120
+ const base = bases.find((candidate) =>
121
+ PcbScene3dCompanionBasePlacementAdjuster.#isPlacementOnBase(
122
+ placement,
123
+ candidate
124
+ )
125
+ )
126
+ if (!base) {
127
+ return placement
128
+ }
129
+
130
+ return {
131
+ ...placement,
132
+ positionMil: {
133
+ ...(placement?.positionMil || {}),
134
+ z: PcbScene3dCompanionBasePlacementAdjuster.#roundMil(
135
+ Number(placement?.positionMil?.z || 0) + base.signedLiftMil
136
+ )
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Checks whether a placement should be lifted onto one companion base.
143
+ * @param {object} placement Candidate placement.
144
+ * @param {object} base Companion base descriptor.
145
+ * @returns {boolean}
146
+ */
147
+ static #isPlacementOnBase(placement, base) {
148
+ if (
149
+ !PcbScene3dCompanionBasePlacementAdjuster.#isSameSide(
150
+ placement,
151
+ base
152
+ )
153
+ ) {
154
+ return false
155
+ }
156
+
157
+ const local =
158
+ PcbScene3dCompanionBasePlacementAdjuster.#toBaseLocalPosition(
159
+ placement,
160
+ base
161
+ )
162
+ const envelopeMarginMil =
163
+ PcbScene3dCompanionBasePlacementAdjuster.#envelopeMarginMil(base)
164
+
165
+ return (
166
+ Math.abs(local.x) <= base.widthMil / 2 + envelopeMarginMil &&
167
+ Math.abs(local.y) <= base.depthMil / 2 + envelopeMarginMil
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Checks whether a placement and base are on the same board side.
173
+ * @param {object} placement Candidate placement.
174
+ * @param {object} base Companion base descriptor.
175
+ * @returns {boolean}
176
+ */
177
+ static #isSameSide(placement, base) {
178
+ return (
179
+ String(placement?.mountSide || 'top').toLowerCase() ===
180
+ String(base?.mountSide || 'top').toLowerCase()
181
+ )
182
+ }
183
+
184
+ /**
185
+ * Converts a placement anchor into a companion-base local frame.
186
+ * @param {object} placement Candidate placement.
187
+ * @param {object} base Companion base descriptor.
188
+ * @returns {{ x: number, y: number }}
189
+ */
190
+ static #toBaseLocalPosition(placement, base) {
191
+ const dx =
192
+ Number(placement?.positionMil?.x || 0) -
193
+ Number(base?.positionMil?.x || 0)
194
+ const dy =
195
+ Number(placement?.positionMil?.y || 0) -
196
+ Number(base?.positionMil?.y || 0)
197
+ const radians = -((Number(base?.rotationDeg || 0) * Math.PI) / 180)
198
+ const cos = Math.cos(radians)
199
+ const sin = Math.sin(radians)
200
+
201
+ return {
202
+ x: dx * cos - dy * sin,
203
+ y: dx * sin + dy * cos
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Resolves loose matching margin for authored parts near a base edge.
209
+ * @param {object} base Companion base descriptor.
210
+ * @returns {number}
211
+ */
212
+ static #envelopeMarginMil(base) {
213
+ return Math.max(
214
+ PcbScene3dCompanionBasePlacementAdjuster.#ENVELOPE_MIN_MARGIN_MIL,
215
+ Math.min(Number(base?.widthMil || 0), Number(base?.depthMil || 0)) *
216
+ PcbScene3dCompanionBasePlacementAdjuster.#ENVELOPE_MARGIN_RATIO
217
+ )
218
+ }
219
+
220
+ /**
221
+ * Checks whether a mount side is bottom.
222
+ * @param {string | undefined} mountSide Mount side.
223
+ * @returns {boolean}
224
+ */
225
+ static #isBottomSide(mountSide) {
226
+ return String(mountSide || 'top').toLowerCase() === 'bottom'
227
+ }
228
+
229
+ /**
230
+ * Rounds one mil coordinate.
231
+ * @param {number} value Candidate value.
232
+ * @returns {number}
233
+ */
234
+ static #roundMil(value) {
235
+ const rounded =
236
+ Math.round(
237
+ Number(value || 0) /
238
+ PcbScene3dCompanionBasePlacementAdjuster.#POSITION_EPSILON
239
+ ) * PcbScene3dCompanionBasePlacementAdjuster.#POSITION_EPSILON
240
+ return Object.is(rounded, -0) ? 0 : rounded
241
+ }
242
+ }
@@ -1,3 +1,5 @@
1
+ import { PcbScene3dFallbackVisibility } from './PcbScene3dFallbackVisibility.mjs'
2
+
1
3
  /**
2
4
  * Applies per-component hidden state on top of existing 3D visibility toggles.
3
5
  */
@@ -46,7 +48,7 @@ export class PcbScene3dComponentVisibility {
46
48
 
47
49
  /**
48
50
  * Applies component visibility to all selectable render roots.
49
- * @param {{ selectionRoots?: Map<string, Set<any>>, hiddenDesignators?: Set<string>, fallbackBodyRoots?: Map<string, Set<any>>, loadedExternalModelDesignators?: Set<string>, modelSearchExternalModelRoots?: Set<any>, toggles?: { 'external-models'?: boolean, 'fallback-bodies'?: boolean, 'model-search-models'?: boolean }, hasLoadedBoardAssemblyModel?: boolean }} state Visibility state.
51
+ * @param {{ selectionRoots?: Map<string, Set<any>>, selectedDesignator?: string, hiddenDesignators?: Set<string>, fallbackBodyRoots?: Map<string, Set<any>>, loadedExternalModelDesignators?: Set<string>, modelSearchExternalModelRoots?: Set<any>, toggles?: { 'external-models'?: boolean, 'fallback-bodies'?: boolean, 'model-search-models'?: boolean }, hasLoadedBoardAssemblyModel?: boolean }} state Visibility state.
50
52
  * @returns {void}
51
53
  */
52
54
  static apply(state) {
@@ -55,6 +57,11 @@ export class PcbScene3dComponentVisibility {
55
57
  return
56
58
  }
57
59
 
60
+ const selectedVariantGroupKeys =
61
+ PcbScene3dComponentVisibility.#selectedVariantGroupKeys(
62
+ selectionRoots,
63
+ state?.selectedDesignator
64
+ )
58
65
  selectionRoots.forEach((roots, designator) => {
59
66
  roots?.forEach?.((rootObject) => {
60
67
  if (!rootObject) {
@@ -66,6 +73,12 @@ export class PcbScene3dComponentVisibility {
66
73
  state?.hiddenDesignators,
67
74
  designator
68
75
  ) &&
76
+ !PcbScene3dComponentVisibility.#isUnselectedVariantSibling(
77
+ rootObject,
78
+ designator,
79
+ state?.selectedDesignator,
80
+ selectedVariantGroupKeys
81
+ ) &&
69
82
  PcbScene3dComponentVisibility.#resolveRootVisible(
70
83
  state,
71
84
  designator,
@@ -75,6 +88,74 @@ export class PcbScene3dComponentVisibility {
75
88
  })
76
89
  }
77
90
 
91
+ /**
92
+ * Resolves variant groups represented by the selected component roots.
93
+ * @param {Map<string, Set<any>>} selectionRoots Registered selectable roots.
94
+ * @param {string | undefined} selectedDesignator Current selection.
95
+ * @returns {Set<string>}
96
+ */
97
+ static #selectedVariantGroupKeys(selectionRoots, selectedDesignator) {
98
+ const selectedKey =
99
+ PcbScene3dComponentVisibility.#normalizeDesignator(
100
+ selectedDesignator
101
+ )
102
+ const groups = new Set()
103
+ if (!selectedKey) {
104
+ return groups
105
+ }
106
+
107
+ selectionRoots.get(selectedKey)?.forEach?.((rootObject) => {
108
+ const groupKey =
109
+ PcbScene3dComponentVisibility.#variantGroupKey(rootObject)
110
+ if (groupKey) {
111
+ groups.add(groupKey)
112
+ }
113
+ })
114
+ return groups
115
+ }
116
+
117
+ /**
118
+ * Checks whether one selectable root is an alternate of the selection.
119
+ * @param {any} rootObject Selectable root.
120
+ * @param {string} designator Root designator.
121
+ * @param {string | undefined} selectedDesignator Current selection.
122
+ * @param {Set<string>} selectedVariantGroupKeys Selected variant groups.
123
+ * @returns {boolean}
124
+ */
125
+ static #isUnselectedVariantSibling(
126
+ rootObject,
127
+ designator,
128
+ selectedDesignator,
129
+ selectedVariantGroupKeys
130
+ ) {
131
+ const selectedKey =
132
+ PcbScene3dComponentVisibility.#normalizeDesignator(designator)
133
+ const activeKey =
134
+ PcbScene3dComponentVisibility.#normalizeDesignator(
135
+ selectedDesignator
136
+ )
137
+ if (!activeKey || !selectedVariantGroupKeys.size) {
138
+ return false
139
+ }
140
+
141
+ if (selectedKey === activeKey) {
142
+ return false
143
+ }
144
+
145
+ const groupKey =
146
+ PcbScene3dComponentVisibility.#variantGroupKey(rootObject)
147
+ return Boolean(groupKey && selectedVariantGroupKeys.has(groupKey))
148
+ }
149
+
150
+ /**
151
+ * Reads the authored co-located variant group key from a render root.
152
+ * @param {any} rootObject Render root.
153
+ * @returns {string}
154
+ */
155
+ static #variantGroupKey(rootObject) {
156
+ return String(rootObject?.userData?.scene3dVariantGroupKey || '').trim()
157
+ }
158
+
78
159
  /**
79
160
  * Resolves root-level visibility before selected-component hiding.
80
161
  * @param {{ fallbackBodyRoots?: Map<string, Set<any>>, loadedExternalModelDesignators?: Set<string>, modelSearchExternalModelRoots?: Set<any>, toggles?: { 'external-models'?: boolean, 'fallback-bodies'?: boolean, 'model-search-models'?: boolean }, hasLoadedBoardAssemblyModel?: boolean }} state Visibility state.
@@ -92,7 +173,8 @@ export class PcbScene3dComponentVisibility {
92
173
  ) {
93
174
  return PcbScene3dComponentVisibility.#resolveFallbackVisible(
94
175
  state,
95
- designator
176
+ designator,
177
+ rootObject
96
178
  )
97
179
  }
98
180
 
@@ -110,14 +192,27 @@ export class PcbScene3dComponentVisibility {
110
192
  * Resolves fallback root visibility from current detail toggles.
111
193
  * @param {{ loadedExternalModelDesignators?: Set<string>, toggles?: { 'external-models'?: boolean, 'fallback-bodies'?: boolean }, hasLoadedBoardAssemblyModel?: boolean }} state Visibility state.
112
194
  * @param {string} designator Component designator.
195
+ * @param {any} rootObject Render root.
113
196
  * @returns {boolean}
114
197
  */
115
- static #resolveFallbackVisible(state, designator) {
198
+ static #resolveFallbackVisible(state, designator, rootObject) {
116
199
  const boardAssemblyActive =
117
200
  Boolean(state?.hasLoadedBoardAssemblyModel) &&
118
201
  Boolean(state?.toggles?.['external-models'])
119
- const showFallbackBodies =
120
- Boolean(state?.toggles?.['fallback-bodies']) && !boardAssemblyActive
202
+ if (boardAssemblyActive) {
203
+ return false
204
+ }
205
+
206
+ if (
207
+ PcbScene3dFallbackVisibility.shouldKeepExternalCompanion(
208
+ rootObject,
209
+ state?.toggles
210
+ )
211
+ ) {
212
+ return true
213
+ }
214
+
215
+ const showFallbackBodies = Boolean(state?.toggles?.['fallback-bodies'])
121
216
  const hideForLoadedExternal =
122
217
  Boolean(state?.toggles?.['external-models']) &&
123
218
  state?.loadedExternalModelDesignators?.has?.(