pcb-scene3d-viewer 1.0.1
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/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +15 -0
- package/CONTRIBUTING.md +14 -0
- package/LICENSE +19 -0
- package/LICENSES/AGPL-3.0-or-later.txt +235 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/LGPL-2.1-or-later.txt +176 -0
- package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
- package/NOTICE.md +36 -0
- package/README.md +128 -0
- package/REUSE.toml +16 -0
- package/docs/api.md +148 -0
- package/docs/circuitjson.md +190 -0
- package/docs/model-format.md +117 -0
- package/docs/testing.md +23 -0
- package/package.json +65 -0
- package/spec/library-scope.md +36 -0
- package/src/PcbModelArchiveExporter.mjs +320 -0
- package/src/PcbScene3dArcUtils.mjs +27 -0
- package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
- package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
- package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
- package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
- package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
- package/src/PcbScene3dCameraRig.mjs +168 -0
- package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
- package/src/PcbScene3dController.mjs +956 -0
- package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
- package/src/PcbScene3dCopperFactory.mjs +559 -0
- package/src/PcbScene3dCopperTextFactory.mjs +534 -0
- package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
- package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
- package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
- package/src/PcbScene3dDrillPathFactory.mjs +362 -0
- package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
- package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
- package/src/PcbScene3dExternalModels.mjs +968 -0
- package/src/PcbScene3dFallbackVisibility.mjs +82 -0
- package/src/PcbScene3dInteractionHints.mjs +56 -0
- package/src/PcbScene3dMountRig.mjs +53 -0
- package/src/PcbScene3dOutlineBuilder.mjs +210 -0
- package/src/PcbScene3dPadFactory.mjs +553 -0
- package/src/PcbScene3dPresetState.mjs +48 -0
- package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
- package/src/PcbScene3dRuntime.mjs +996 -0
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
- package/src/PcbScene3dSelectionStyler.mjs +252 -0
- package/src/PcbScene3dShapePathFactory.mjs +220 -0
- package/src/PcbScene3dShellRenderer.mjs +131 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
- package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
- package/src/PcbScene3dStepLoader.mjs +611 -0
- package/src/PcbScene3dStrokeFont.mjs +671 -0
- package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
- package/src/PcbScene3dText.mjs +99 -0
- package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
- package/src/PcbScene3dViaFactory.mjs +176 -0
- package/src/PcbScene3dViewCompensation.mjs +109 -0
- package/src/PcbScene3dViewScale.mjs +24 -0
- package/src/PcbScene3dViewportResize.mjs +35 -0
- package/src/PcbScene3dWorkerClient.mjs +123 -0
- package/src/index.mjs +1 -0
- package/src/scene3d.mjs +44 -0
- package/src/styles/scene3d.css +295 -0
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
import { PcbScene3dBoardAssemblyPresentation } from './PcbScene3dBoardAssemblyPresentation.mjs'
|
|
2
|
+
import { PcbScene3dBoardAssemblyPlacement } from './PcbScene3dBoardAssemblyPlacement.mjs'
|
|
3
|
+
import { PcbScene3dExternalModelLoadOrder } from './PcbScene3dExternalModelLoadOrder.mjs'
|
|
4
|
+
import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
|
|
5
|
+
import { PcbScene3dStepLoader } from './PcbScene3dStepLoader.mjs'
|
|
6
|
+
import { PcbScene3dViewCompensation } from './PcbScene3dViewCompensation.mjs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Loads external 3D models into the Three.js PCB scene.
|
|
10
|
+
*/
|
|
11
|
+
export class PcbScene3dExternalModels {
|
|
12
|
+
/**
|
|
13
|
+
* Loads all available external models into the supplied group.
|
|
14
|
+
* @param {{ three: any, sceneDescription: any, externalModelsGroup: any, modelViewScale?: { x?: number, y?: number, z?: number }, isDisposed?: () => boolean, onPlacementGroup?: (placement: any, placementGroup: any) => void, stepLoader?: PcbScene3dStepLoader }} options
|
|
15
|
+
* @returns {Promise<string[]>}
|
|
16
|
+
*/
|
|
17
|
+
static async loadIntoScene(options) {
|
|
18
|
+
const placements = PcbScene3dExternalModelLoadOrder.sort(
|
|
19
|
+
PcbScene3dExternalModels.#resolvePlacements(
|
|
20
|
+
options?.sceneDescription
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
const externalModelsGroup = options?.externalModelsGroup
|
|
24
|
+
if (!placements.length || !externalModelsGroup || !options?.three) {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const diagnostics = []
|
|
29
|
+
const ownsStepLoader = !options?.stepLoader
|
|
30
|
+
const stepLoader = options?.stepLoader || new PcbScene3dStepLoader()
|
|
31
|
+
const cachedModelGroups = new Map()
|
|
32
|
+
let processedPlacements = 0
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
for (const placement of placements) {
|
|
36
|
+
if (options?.isDisposed?.()) {
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const loadedGroup =
|
|
42
|
+
await PcbScene3dExternalModels.#loadPlacementGroup(
|
|
43
|
+
options.three,
|
|
44
|
+
placement,
|
|
45
|
+
stepLoader,
|
|
46
|
+
cachedModelGroups,
|
|
47
|
+
options?.modelViewScale
|
|
48
|
+
)
|
|
49
|
+
if (!loadedGroup || options?.isDisposed?.()) {
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
externalModelsGroup.add(loadedGroup)
|
|
54
|
+
options?.onPlacementGroup?.(placement, loadedGroup)
|
|
55
|
+
processedPlacements += 1
|
|
56
|
+
if (processedPlacements % 12 === 0) {
|
|
57
|
+
await PcbScene3dExternalModels.#yieldToMainThread()
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
diagnostics.push(
|
|
61
|
+
'Could not load external model for ' +
|
|
62
|
+
String(placement?.designator || 'component') +
|
|
63
|
+
': ' +
|
|
64
|
+
String(error?.message || error || 'Unknown error.')
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
if (ownsStepLoader) {
|
|
70
|
+
stepLoader.dispose?.()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return diagnostics
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Applies the active view mirror compensation to loaded model geometry.
|
|
79
|
+
* @param {any} externalModelsGroup Root group containing placed models.
|
|
80
|
+
* @param {{ x?: number, y?: number, z?: number } | null | undefined} viewScale Active scene view scale.
|
|
81
|
+
* @returns {void}
|
|
82
|
+
*/
|
|
83
|
+
static applyViewCompensation(externalModelsGroup, viewScale) {
|
|
84
|
+
PcbScene3dViewCompensation.apply(externalModelsGroup, viewScale)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves the external-model placements the runtime should render.
|
|
89
|
+
* @param {{ board?: { widthMil?: number, heightMil?: number, thicknessMil?: number }, boardAssemblyModel?: any, components?: any[], externalPlacements?: any[] }} sceneDescription
|
|
90
|
+
* @returns {any[]}
|
|
91
|
+
*/
|
|
92
|
+
static #resolvePlacements(sceneDescription) {
|
|
93
|
+
const boardAssemblyPlacement =
|
|
94
|
+
PcbScene3dBoardAssemblyPlacement.build(sceneDescription)
|
|
95
|
+
if (boardAssemblyPlacement) {
|
|
96
|
+
return [boardAssemblyPlacement]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const explicitPlacements = Array.isArray(
|
|
100
|
+
sceneDescription?.externalPlacements
|
|
101
|
+
)
|
|
102
|
+
? sceneDescription.externalPlacements
|
|
103
|
+
: []
|
|
104
|
+
const explicitDesignators = new Set(
|
|
105
|
+
explicitPlacements
|
|
106
|
+
.map((placement) => String(placement?.designator || '').trim())
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
)
|
|
109
|
+
const fallbackPlacements = Array.isArray(sceneDescription?.components)
|
|
110
|
+
? sceneDescription.components
|
|
111
|
+
.filter(
|
|
112
|
+
(component) =>
|
|
113
|
+
component?.externalModel &&
|
|
114
|
+
!explicitDesignators.has(
|
|
115
|
+
String(component?.designator || '').trim()
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
.map((component) =>
|
|
119
|
+
PcbScene3dExternalModels.#buildFallbackPlacement(
|
|
120
|
+
component
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
: []
|
|
124
|
+
|
|
125
|
+
return [...explicitPlacements, ...fallbackPlacements]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Builds one legacy component placement into the explicit placement shape.
|
|
130
|
+
* @param {{ designator?: string, mountSide?: string, rotationDeg?: number, positionMil?: { x?: number, y?: number, z?: number }, modelTransform?: object, externalModel?: any }} component
|
|
131
|
+
* @returns {any}
|
|
132
|
+
*/
|
|
133
|
+
static #buildFallbackPlacement(component) {
|
|
134
|
+
return {
|
|
135
|
+
designator: String(component?.designator || 'component'),
|
|
136
|
+
mountSide: String(component?.mountSide || 'top'),
|
|
137
|
+
rotationDeg: Number(component?.rotationDeg || 0),
|
|
138
|
+
positionMil: {
|
|
139
|
+
x: Number(component?.positionMil?.x || 0),
|
|
140
|
+
y: Number(component?.positionMil?.y || 0),
|
|
141
|
+
z: Number(component?.positionMil?.z || 0)
|
|
142
|
+
},
|
|
143
|
+
bodyPositionMil: { x: 0, y: 0 },
|
|
144
|
+
bodyRotationDeg: 0,
|
|
145
|
+
modelTransform: PcbScene3dExternalModels.#normalizeModelTransform(
|
|
146
|
+
component?.modelTransform
|
|
147
|
+
),
|
|
148
|
+
externalModel: component?.externalModel || null
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Loads one model group for one resolved placement.
|
|
154
|
+
* @param {any} THREE
|
|
155
|
+
* @param {{ mountSide?: string, rotationDeg?: number, positionMil?: { x?: number, y?: number, z?: number }, modelTransform?: object, externalModel?: any }} placement
|
|
156
|
+
* @param {PcbScene3dStepLoader} stepLoader
|
|
157
|
+
* @param {Map<string, any>} cachedModelGroups
|
|
158
|
+
* @param {{ x?: number, y?: number, z?: number } | null | undefined} modelViewScale Active scene view scale.
|
|
159
|
+
* @returns {Promise<any>}
|
|
160
|
+
*/
|
|
161
|
+
static async #loadPlacementGroup(
|
|
162
|
+
THREE,
|
|
163
|
+
placement,
|
|
164
|
+
stepLoader,
|
|
165
|
+
cachedModelGroups,
|
|
166
|
+
modelViewScale
|
|
167
|
+
) {
|
|
168
|
+
const model = placement?.externalModel
|
|
169
|
+
if (!model) {
|
|
170
|
+
throw new Error('Placement has no resolved model.')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const templateGroup =
|
|
174
|
+
await PcbScene3dExternalModels.#loadCachedModelGroup(
|
|
175
|
+
THREE,
|
|
176
|
+
model,
|
|
177
|
+
stepLoader,
|
|
178
|
+
cachedModelGroups
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return PcbScene3dExternalModels.#buildPlacementWrapper(
|
|
182
|
+
THREE,
|
|
183
|
+
placement,
|
|
184
|
+
PcbScene3dExternalModels.#cloneModelGroup(templateGroup),
|
|
185
|
+
modelViewScale
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Loads or reuses one model template group for repeated placements that
|
|
191
|
+
* resolve to the same source identity.
|
|
192
|
+
* @param {any} THREE
|
|
193
|
+
* @param {any} model
|
|
194
|
+
* @param {PcbScene3dStepLoader} stepLoader
|
|
195
|
+
* @param {Map<string, any>} cachedModelGroups
|
|
196
|
+
* @returns {Promise<any>}
|
|
197
|
+
*/
|
|
198
|
+
static async #loadCachedModelGroup(
|
|
199
|
+
THREE,
|
|
200
|
+
model,
|
|
201
|
+
stepLoader,
|
|
202
|
+
cachedModelGroups
|
|
203
|
+
) {
|
|
204
|
+
const identity = PcbScene3dExternalModels.#resolveModelIdentity(model)
|
|
205
|
+
if (cachedModelGroups.has(identity)) {
|
|
206
|
+
return cachedModelGroups.get(identity)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const modelGroup = await PcbScene3dExternalModels.#loadModelGroup(
|
|
210
|
+
THREE,
|
|
211
|
+
model,
|
|
212
|
+
stepLoader
|
|
213
|
+
)
|
|
214
|
+
cachedModelGroups.set(identity, modelGroup)
|
|
215
|
+
|
|
216
|
+
return modelGroup
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Loads one raw model group without placement-specific mount transforms.
|
|
221
|
+
* @param {any} THREE
|
|
222
|
+
* @param {any} model
|
|
223
|
+
* @param {PcbScene3dStepLoader} stepLoader
|
|
224
|
+
* @returns {Promise<any>}
|
|
225
|
+
*/
|
|
226
|
+
static async #loadModelGroup(THREE, model, stepLoader) {
|
|
227
|
+
if (model.format === 'wrl') {
|
|
228
|
+
if (!model.file) {
|
|
229
|
+
throw new Error('Resolved WRL model file is unavailable.')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return PcbScene3dExternalModels.#loadVrmlModel(model.file)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (model.format === 'step') {
|
|
236
|
+
return PcbScene3dExternalModels.#loadStepModel(
|
|
237
|
+
THREE,
|
|
238
|
+
model,
|
|
239
|
+
stepLoader
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
throw new Error('Unsupported external model format.')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Wraps one loaded model group in its placement-specific mount rig.
|
|
248
|
+
* @param {any} THREE
|
|
249
|
+
* @param {{ mountSide?: string, modelTransform?: object, designator?: string }} placement
|
|
250
|
+
* @param {any} modelGroup
|
|
251
|
+
* @param {{ x?: number, y?: number, z?: number } | null | undefined} modelViewScale Active scene view scale.
|
|
252
|
+
* @returns {any}
|
|
253
|
+
*/
|
|
254
|
+
static #buildPlacementWrapper(
|
|
255
|
+
THREE,
|
|
256
|
+
placement,
|
|
257
|
+
modelGroup,
|
|
258
|
+
modelViewScale
|
|
259
|
+
) {
|
|
260
|
+
if (PcbScene3dExternalModels.#isBoardAssemblyPlacement(placement)) {
|
|
261
|
+
return PcbScene3dExternalModels.#buildBoardAssemblyWrapper(
|
|
262
|
+
THREE,
|
|
263
|
+
placement,
|
|
264
|
+
modelGroup
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const mountRig = PcbScene3dMountRig.create(THREE, placement)
|
|
269
|
+
const wrapperGroup = mountRig.rootGroup
|
|
270
|
+
const viewCompensationGroup = new THREE.Group()
|
|
271
|
+
wrapperGroup.userData.scene3dSelection = {
|
|
272
|
+
designator: String(placement?.designator || 'component'),
|
|
273
|
+
sourceType: 'external-model'
|
|
274
|
+
}
|
|
275
|
+
viewCompensationGroup.userData.scene3dViewCompensation = true
|
|
276
|
+
viewCompensationGroup.userData.scene3dSourceFrameScale =
|
|
277
|
+
PcbScene3dExternalModels.#resolveSourceFrameScale(placement)
|
|
278
|
+
viewCompensationGroup.userData.scene3dViewCompensationAxes =
|
|
279
|
+
PcbScene3dExternalModels.#resolveViewCompensationAxes(placement)
|
|
280
|
+
PcbScene3dViewCompensation.applyToGroup(
|
|
281
|
+
viewCompensationGroup,
|
|
282
|
+
modelViewScale
|
|
283
|
+
)
|
|
284
|
+
PcbScene3dExternalModels.#wrapPlacementOrientation(
|
|
285
|
+
wrapperGroup,
|
|
286
|
+
mountRig.orientationGroup,
|
|
287
|
+
viewCompensationGroup
|
|
288
|
+
)
|
|
289
|
+
const modelTransform = placement?.modelTransform || {}
|
|
290
|
+
const modelRotation = modelTransform.rotationDeg || {}
|
|
291
|
+
const modelOffset =
|
|
292
|
+
PcbScene3dExternalModels.#resolveModelOffset(modelTransform)
|
|
293
|
+
const sourceOriginAdjustment =
|
|
294
|
+
PcbScene3dExternalModels.#resolveEmbeddedSourceOriginAdjustment(
|
|
295
|
+
placement,
|
|
296
|
+
modelGroup,
|
|
297
|
+
modelRotation
|
|
298
|
+
)
|
|
299
|
+
const adjustedModelRotation = {
|
|
300
|
+
x:
|
|
301
|
+
Number(modelRotation.x || 0) +
|
|
302
|
+
sourceOriginAdjustment.rotationDeg.x,
|
|
303
|
+
y:
|
|
304
|
+
Number(modelRotation.y || 0) +
|
|
305
|
+
sourceOriginAdjustment.rotationDeg.y,
|
|
306
|
+
z:
|
|
307
|
+
Number(modelRotation.z || 0) +
|
|
308
|
+
sourceOriginAdjustment.rotationDeg.z
|
|
309
|
+
}
|
|
310
|
+
modelGroup.position.set(
|
|
311
|
+
modelOffset.x + sourceOriginAdjustment.offset.x,
|
|
312
|
+
modelOffset.y + sourceOriginAdjustment.offset.y,
|
|
313
|
+
modelOffset.z + sourceOriginAdjustment.offset.z
|
|
314
|
+
)
|
|
315
|
+
PcbScene3dExternalModels.#applyModelRotation(
|
|
316
|
+
THREE,
|
|
317
|
+
modelGroup,
|
|
318
|
+
adjustedModelRotation
|
|
319
|
+
)
|
|
320
|
+
PcbScene3dExternalModels.#applyModelScale(
|
|
321
|
+
modelGroup,
|
|
322
|
+
PcbScene3dExternalModels.#resolveModelScale(modelTransform)
|
|
323
|
+
)
|
|
324
|
+
mountRig.faceGroup.add(modelGroup)
|
|
325
|
+
|
|
326
|
+
return wrapperGroup
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Wraps a full board assembly model in board-local scene coordinates.
|
|
331
|
+
* @param {any} THREE
|
|
332
|
+
* @param {{ positionMil?: { x?: number, y?: number, z?: number }, board?: { widthMil?: number, heightMil?: number, thicknessMil?: number } }} placement
|
|
333
|
+
* @param {any} modelGroup
|
|
334
|
+
* @returns {any}
|
|
335
|
+
*/
|
|
336
|
+
static #buildBoardAssemblyWrapper(THREE, placement, modelGroup) {
|
|
337
|
+
const wrapperGroup = new THREE.Group()
|
|
338
|
+
const positionMil = placement?.positionMil || {}
|
|
339
|
+
|
|
340
|
+
PcbScene3dBoardAssemblyPresentation.apply(modelGroup, placement?.board)
|
|
341
|
+
wrapperGroup.position.set(
|
|
342
|
+
Number(positionMil.x || 0),
|
|
343
|
+
Number(positionMil.y || 0),
|
|
344
|
+
Number(positionMil.z || 0)
|
|
345
|
+
)
|
|
346
|
+
wrapperGroup.userData.scene3dPlacementType = 'board-assembly'
|
|
347
|
+
wrapperGroup.add(modelGroup)
|
|
348
|
+
|
|
349
|
+
return wrapperGroup
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Checks whether one placement represents a full board assembly model.
|
|
354
|
+
* @param {{ sourceType?: string, externalModel?: { origin?: string } }} placement
|
|
355
|
+
* @returns {boolean}
|
|
356
|
+
*/
|
|
357
|
+
static #isBoardAssemblyPlacement(placement) {
|
|
358
|
+
return (
|
|
359
|
+
String(placement?.sourceType || '').toLowerCase() ===
|
|
360
|
+
'board-assembly' ||
|
|
361
|
+
String(placement?.externalModel?.origin || '').toLowerCase() ===
|
|
362
|
+
'board-assembly'
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Places view compensation before footprint orientation so scene mirrors
|
|
368
|
+
* cancel globally instead of reflecting each rotated model locally.
|
|
369
|
+
* @param {any} wrapperGroup Placement root group.
|
|
370
|
+
* @param {any} orientationGroup Placement orientation group.
|
|
371
|
+
* @param {any} viewCompensationGroup View compensation group.
|
|
372
|
+
* @returns {void}
|
|
373
|
+
*/
|
|
374
|
+
static #wrapPlacementOrientation(
|
|
375
|
+
wrapperGroup,
|
|
376
|
+
orientationGroup,
|
|
377
|
+
viewCompensationGroup
|
|
378
|
+
) {
|
|
379
|
+
if (
|
|
380
|
+
typeof wrapperGroup?.remove === 'function' &&
|
|
381
|
+
typeof wrapperGroup?.add === 'function'
|
|
382
|
+
) {
|
|
383
|
+
wrapperGroup.remove(orientationGroup)
|
|
384
|
+
wrapperGroup.add(viewCompensationGroup)
|
|
385
|
+
} else if (Array.isArray(wrapperGroup?.children)) {
|
|
386
|
+
const orientationIndex =
|
|
387
|
+
wrapperGroup.children.indexOf(orientationGroup)
|
|
388
|
+
if (orientationIndex !== -1) {
|
|
389
|
+
wrapperGroup.children.splice(
|
|
390
|
+
orientationIndex,
|
|
391
|
+
1,
|
|
392
|
+
viewCompensationGroup
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
viewCompensationGroup.add(orientationGroup)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Resolves the model source-frame scale applied before footprint rotation.
|
|
402
|
+
* @param {{ externalModel?: { origin?: string } }} placement Placement metadata.
|
|
403
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
404
|
+
*/
|
|
405
|
+
static #resolveSourceFrameScale(placement) {
|
|
406
|
+
if (!PcbScene3dExternalModels.#isEmbeddedExternalModel(placement)) {
|
|
407
|
+
return { x: 1, y: 1, z: 1 }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { x: 1, y: -1, z: 1 }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Resolves view compensation axes for one placement wrapper.
|
|
415
|
+
* @param {{ externalModel?: { origin?: string } }} placement Placement metadata.
|
|
416
|
+
* @returns {{ x?: boolean, y?: boolean, z?: boolean }}
|
|
417
|
+
*/
|
|
418
|
+
static #resolveViewCompensationAxes(placement) {
|
|
419
|
+
if (!PcbScene3dExternalModels.#isEmbeddedExternalModel(placement)) {
|
|
420
|
+
return {}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return { x: false, y: false, z: false }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Checks whether a placement uses an embedded Altium model source frame.
|
|
428
|
+
* @param {{ externalModel?: { origin?: string } }} placement Placement metadata.
|
|
429
|
+
* @returns {boolean}
|
|
430
|
+
*/
|
|
431
|
+
static #isEmbeddedExternalModel(placement) {
|
|
432
|
+
return (
|
|
433
|
+
String(placement?.externalModel?.origin || '').toLowerCase() ===
|
|
434
|
+
'embedded'
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Adjusts embedded Altium models whose source geometry is authored with a
|
|
440
|
+
* strong Z-origin bias. A signed X tilt lays those models flat correctly,
|
|
441
|
+
* but source Z becomes lateral board Y. Strongly asymmetric source centers
|
|
442
|
+
* also need an in-plane flip while preserving the body-origin offset
|
|
443
|
+
* already encoded by the source geometry.
|
|
444
|
+
* @param {{ externalModel?: { origin?: string } }} placement Placement metadata.
|
|
445
|
+
* @param {{ userData?: { scene3dSourceBoundsMil?: { centerX?: number, centerZ?: number, sizeX?: number, sizeY?: number, sizeZ?: number } } }} modelGroup Loaded model group.
|
|
446
|
+
* @param {{ x?: number }} modelRotation Model-local rotation.
|
|
447
|
+
* @returns {{ offset: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number } }}
|
|
448
|
+
*/
|
|
449
|
+
static #resolveEmbeddedSourceOriginAdjustment(
|
|
450
|
+
placement,
|
|
451
|
+
modelGroup,
|
|
452
|
+
modelRotation
|
|
453
|
+
) {
|
|
454
|
+
const bounds = modelGroup?.userData?.scene3dSourceBoundsMil || null
|
|
455
|
+
const centerX = Number(bounds?.centerX || 0)
|
|
456
|
+
const centerZ = Number(bounds?.centerZ || 0)
|
|
457
|
+
const maxDimension = Math.max(
|
|
458
|
+
Math.abs(Number(bounds?.sizeX || 0)),
|
|
459
|
+
Math.abs(Number(bounds?.sizeY || 0)),
|
|
460
|
+
Math.abs(Number(bounds?.sizeZ || 0))
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
if (
|
|
464
|
+
String(placement?.externalModel?.origin || '').toLowerCase() !==
|
|
465
|
+
'embedded' ||
|
|
466
|
+
PcbScene3dExternalModels.#normalizeAngle(modelRotation?.x) !==
|
|
467
|
+
270 ||
|
|
468
|
+
!Number.isFinite(centerX) ||
|
|
469
|
+
!Number.isFinite(centerZ) ||
|
|
470
|
+
!Number.isFinite(maxDimension) ||
|
|
471
|
+
maxDimension <= 0 ||
|
|
472
|
+
Math.abs(centerZ) <= maxDimension * 0.2
|
|
473
|
+
) {
|
|
474
|
+
return PcbScene3dExternalModels.#emptySourceOriginAdjustment()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (Math.abs(centerX) > maxDimension * 0.2) {
|
|
478
|
+
return {
|
|
479
|
+
offset: { x: 0, y: 0, z: 0 },
|
|
480
|
+
rotationDeg: { x: 0, y: 0, z: 180 }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
offset: { x: 0, y: centerZ * 2, z: 0 },
|
|
486
|
+
rotationDeg: { x: 0, y: 0, z: 0 }
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Returns the neutral embedded-source adjustment shape.
|
|
492
|
+
* @returns {{ offset: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number } }}
|
|
493
|
+
*/
|
|
494
|
+
static #emptySourceOriginAdjustment() {
|
|
495
|
+
return {
|
|
496
|
+
offset: { x: 0, y: 0, z: 0 },
|
|
497
|
+
rotationDeg: { x: 0, y: 0, z: 0 }
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Normalizes one angle into [0, 360).
|
|
503
|
+
* @param {number | string | undefined} angle Source angle.
|
|
504
|
+
* @returns {number}
|
|
505
|
+
*/
|
|
506
|
+
static #normalizeAngle(angle) {
|
|
507
|
+
const normalized = Number(angle || 0) % 360
|
|
508
|
+
|
|
509
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Applies KiCad's 3D model rotation order to one loaded model group.
|
|
514
|
+
* @param {any} THREE Three.js namespace.
|
|
515
|
+
* @param {any} modelGroup Loaded model group.
|
|
516
|
+
* @param {{ x?: number, y?: number, z?: number }} modelRotation Model rotation.
|
|
517
|
+
* @returns {void}
|
|
518
|
+
*/
|
|
519
|
+
static #applyModelRotation(THREE, modelGroup, modelRotation) {
|
|
520
|
+
const x = (-Number(modelRotation.x || 0) * Math.PI) / 180
|
|
521
|
+
const y = (-Number(modelRotation.y || 0) * Math.PI) / 180
|
|
522
|
+
const z = (-Number(modelRotation.z || 0) * Math.PI) / 180
|
|
523
|
+
|
|
524
|
+
if (THREE?.Matrix4 && modelGroup?.quaternion?.setFromRotationMatrix) {
|
|
525
|
+
const rotationMatrix = new THREE.Matrix4()
|
|
526
|
+
.makeRotationZ(z)
|
|
527
|
+
.multiply(new THREE.Matrix4().makeRotationY(y))
|
|
528
|
+
.multiply(new THREE.Matrix4().makeRotationX(x))
|
|
529
|
+
|
|
530
|
+
modelGroup.quaternion.setFromRotationMatrix(rotationMatrix)
|
|
531
|
+
return
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!modelGroup?.rotation) {
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
modelGroup.rotation.x = x
|
|
539
|
+
modelGroup.rotation.y = y
|
|
540
|
+
modelGroup.rotation.z = z
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Normalizes optional model transform metadata.
|
|
545
|
+
* @param {object | null | undefined} modelTransform Model transform.
|
|
546
|
+
* @returns {{ rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number }, dxMil: number, dyMil: number, dzMil: number, scale: { x: number, y: number, z: number } }}
|
|
547
|
+
*/
|
|
548
|
+
static #normalizeModelTransform(modelTransform) {
|
|
549
|
+
const offsetMil =
|
|
550
|
+
PcbScene3dExternalModels.#resolveModelOffset(modelTransform)
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
rotationDeg: {
|
|
554
|
+
x: Number(modelTransform?.rotationDeg?.x || 0),
|
|
555
|
+
y: Number(modelTransform?.rotationDeg?.y || 0),
|
|
556
|
+
z: Number(modelTransform?.rotationDeg?.z || 0)
|
|
557
|
+
},
|
|
558
|
+
offsetMil,
|
|
559
|
+
dxMil: offsetMil.x,
|
|
560
|
+
dyMil: offsetMil.y,
|
|
561
|
+
dzMil: offsetMil.z,
|
|
562
|
+
scale: PcbScene3dExternalModels.#resolveModelScale(modelTransform)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Resolves model offset from current and legacy transform shapes.
|
|
568
|
+
* @param {object | null | undefined} modelTransform Model transform.
|
|
569
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
570
|
+
*/
|
|
571
|
+
static #resolveModelOffset(modelTransform) {
|
|
572
|
+
const offsetMil = modelTransform?.offsetMil || {}
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
x: Number(offsetMil.x ?? modelTransform?.dxMil ?? 0),
|
|
576
|
+
y: Number(offsetMil.y ?? modelTransform?.dyMil ?? 0),
|
|
577
|
+
z: Number(offsetMil.z ?? modelTransform?.dzMil ?? 0)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Resolves model scale from current and legacy transform shapes.
|
|
583
|
+
* @param {object | null | undefined} modelTransform Model transform.
|
|
584
|
+
* @returns {{ x: number, y: number, z: number }}
|
|
585
|
+
*/
|
|
586
|
+
static #resolveModelScale(modelTransform) {
|
|
587
|
+
const scale = modelTransform?.scale || {}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
x: Number(scale.x ?? 1) || 1,
|
|
591
|
+
y: Number(scale.y ?? 1) || 1,
|
|
592
|
+
z: Number(scale.z ?? 1) || 1
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Applies model scale while preserving importer unit conversion already on
|
|
598
|
+
* the loaded group.
|
|
599
|
+
* @param {any} modelGroup Loaded model group.
|
|
600
|
+
* @param {{ x: number, y: number, z: number }} scale Placement scale.
|
|
601
|
+
* @returns {void}
|
|
602
|
+
*/
|
|
603
|
+
static #applyModelScale(modelGroup, scale) {
|
|
604
|
+
if (!modelGroup?.scale) {
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
modelGroup.scale.x *= scale.x
|
|
609
|
+
modelGroup.scale.y *= scale.y
|
|
610
|
+
modelGroup.scale.z *= scale.z
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Clones one cached model template so per-placement transforms do not
|
|
615
|
+
* mutate the shared base geometry.
|
|
616
|
+
* @param {any} modelGroup
|
|
617
|
+
* @returns {any}
|
|
618
|
+
*/
|
|
619
|
+
static #cloneModelGroup(modelGroup) {
|
|
620
|
+
if (typeof modelGroup?.clone === 'function') {
|
|
621
|
+
return modelGroup.clone(true)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return modelGroup
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Resolves one stable cache key for repeated placements of the same model.
|
|
629
|
+
* @param {{ origin?: string, sourceStream?: string, relativePath?: string, name?: string, checksum?: number | null, format?: string }} model
|
|
630
|
+
* @returns {string}
|
|
631
|
+
*/
|
|
632
|
+
static #resolveModelIdentity(model) {
|
|
633
|
+
if (String(model?.origin || '') === 'embedded') {
|
|
634
|
+
return [
|
|
635
|
+
'embedded',
|
|
636
|
+
String(model?.sourceStream || ''),
|
|
637
|
+
String(model?.name || ''),
|
|
638
|
+
String(model?.checksum || ''),
|
|
639
|
+
String(model?.format || '')
|
|
640
|
+
].join('::')
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return [
|
|
644
|
+
'session',
|
|
645
|
+
String(model?.relativePath || ''),
|
|
646
|
+
String(model?.name || ''),
|
|
647
|
+
String(model?.format || '')
|
|
648
|
+
].join('::')
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Yields control back to the browser during long model-placement runs so
|
|
653
|
+
* large boards do not monopolize the main thread.
|
|
654
|
+
* @returns {Promise<void>}
|
|
655
|
+
*/
|
|
656
|
+
static async #yieldToMainThread() {
|
|
657
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
658
|
+
await new Promise((resolve) => {
|
|
659
|
+
requestAnimationFrame(() => resolve())
|
|
660
|
+
})
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
await new Promise((resolve) => {
|
|
665
|
+
setTimeout(resolve, 0)
|
|
666
|
+
})
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Loads one VRML model from a browser file.
|
|
671
|
+
* @param {File | Blob} file
|
|
672
|
+
* @returns {Promise<any>}
|
|
673
|
+
*/
|
|
674
|
+
static async #loadVrmlModel(file) {
|
|
675
|
+
const versionKey = new URL(import.meta.url).searchParams.get('v') || ''
|
|
676
|
+
const [{ VRMLLoader }] = await Promise.all([
|
|
677
|
+
import(
|
|
678
|
+
'/node_modules/three/examples/jsm/loaders/VRMLLoader.js' +
|
|
679
|
+
(versionKey ? '?v=' + encodeURIComponent(versionKey) : '')
|
|
680
|
+
)
|
|
681
|
+
])
|
|
682
|
+
const loader = new VRMLLoader()
|
|
683
|
+
const objectUrl = URL.createObjectURL(file)
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
return await new Promise((resolve, reject) => {
|
|
687
|
+
loader.load(
|
|
688
|
+
objectUrl,
|
|
689
|
+
(loadedScene) => resolve(loadedScene),
|
|
690
|
+
undefined,
|
|
691
|
+
reject
|
|
692
|
+
)
|
|
693
|
+
})
|
|
694
|
+
} finally {
|
|
695
|
+
URL.revokeObjectURL(objectUrl)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Loads one STEP model and converts its meshes into Three objects.
|
|
701
|
+
* @param {any} THREE
|
|
702
|
+
* @param {any} model
|
|
703
|
+
* @param {PcbScene3dStepLoader} stepLoader
|
|
704
|
+
* @returns {Promise<any>}
|
|
705
|
+
*/
|
|
706
|
+
static async #loadStepModel(THREE, model, stepLoader) {
|
|
707
|
+
const loadedModel = Array.isArray(model?.preparedMeshPayloads)
|
|
708
|
+
? { meshPayloads: model.preparedMeshPayloads }
|
|
709
|
+
: await stepLoader.loadModel(model)
|
|
710
|
+
const group = new THREE.Group()
|
|
711
|
+
const sourceBounds = PcbScene3dExternalModels.#measureSourceBoundsMil(
|
|
712
|
+
loadedModel.meshPayloads
|
|
713
|
+
)
|
|
714
|
+
group.scale.setScalar(1000)
|
|
715
|
+
if (sourceBounds) {
|
|
716
|
+
group.userData.scene3dSourceBoundsMil = sourceBounds
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
loadedModel.meshPayloads.forEach((meshPayload) => {
|
|
720
|
+
const geometry = new THREE.BufferGeometry()
|
|
721
|
+
geometry.setAttribute(
|
|
722
|
+
'position',
|
|
723
|
+
new THREE.Float32BufferAttribute(meshPayload.positions, 3)
|
|
724
|
+
)
|
|
725
|
+
geometry.setIndex(meshPayload.indices)
|
|
726
|
+
|
|
727
|
+
if (meshPayload.normals.length) {
|
|
728
|
+
geometry.setAttribute(
|
|
729
|
+
'normal',
|
|
730
|
+
new THREE.Float32BufferAttribute(meshPayload.normals, 3)
|
|
731
|
+
)
|
|
732
|
+
} else {
|
|
733
|
+
geometry.computeVertexNormals()
|
|
734
|
+
}
|
|
735
|
+
geometry.computeBoundingSphere()
|
|
736
|
+
|
|
737
|
+
const materials = PcbScene3dExternalModels.#buildStepMeshMaterials(
|
|
738
|
+
THREE,
|
|
739
|
+
geometry,
|
|
740
|
+
meshPayload
|
|
741
|
+
)
|
|
742
|
+
const mesh = new THREE.Mesh(
|
|
743
|
+
geometry,
|
|
744
|
+
materials.length > 1 ? materials : materials[0]
|
|
745
|
+
)
|
|
746
|
+
group.add(mesh)
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
return group
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Measures raw STEP mesh bounds in mil before the group-level unit scale is
|
|
754
|
+
* applied.
|
|
755
|
+
* @param {{ positions?: number[] }[]} meshPayloads STEP mesh payloads.
|
|
756
|
+
* @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}
|
|
757
|
+
*/
|
|
758
|
+
static #measureSourceBoundsMil(meshPayloads) {
|
|
759
|
+
let minX = Number.POSITIVE_INFINITY
|
|
760
|
+
let minY = Number.POSITIVE_INFINITY
|
|
761
|
+
let minZ = Number.POSITIVE_INFINITY
|
|
762
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
763
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
764
|
+
let maxZ = Number.NEGATIVE_INFINITY
|
|
765
|
+
|
|
766
|
+
;(Array.isArray(meshPayloads) ? meshPayloads : []).forEach(
|
|
767
|
+
(meshPayload) => {
|
|
768
|
+
;(Array.isArray(meshPayload?.positions)
|
|
769
|
+
? meshPayload.positions
|
|
770
|
+
: []
|
|
771
|
+
).forEach((value, index) => {
|
|
772
|
+
const numericValue = Number(value || 0) * 1000
|
|
773
|
+
|
|
774
|
+
if (index % 3 === 0) {
|
|
775
|
+
minX = Math.min(minX, numericValue)
|
|
776
|
+
maxX = Math.max(maxX, numericValue)
|
|
777
|
+
} else if (index % 3 === 1) {
|
|
778
|
+
minY = Math.min(minY, numericValue)
|
|
779
|
+
maxY = Math.max(maxY, numericValue)
|
|
780
|
+
} else {
|
|
781
|
+
minZ = Math.min(minZ, numericValue)
|
|
782
|
+
maxZ = Math.max(maxZ, numericValue)
|
|
783
|
+
}
|
|
784
|
+
})
|
|
785
|
+
}
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
if (
|
|
789
|
+
!Number.isFinite(minX) ||
|
|
790
|
+
!Number.isFinite(minY) ||
|
|
791
|
+
!Number.isFinite(minZ) ||
|
|
792
|
+
!Number.isFinite(maxX) ||
|
|
793
|
+
!Number.isFinite(maxY) ||
|
|
794
|
+
!Number.isFinite(maxZ)
|
|
795
|
+
) {
|
|
796
|
+
return null
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
minX,
|
|
801
|
+
minY,
|
|
802
|
+
minZ,
|
|
803
|
+
maxX,
|
|
804
|
+
maxY,
|
|
805
|
+
maxZ,
|
|
806
|
+
centerX: (minX + maxX) / 2,
|
|
807
|
+
centerY: (minY + maxY) / 2,
|
|
808
|
+
centerZ: (minZ + maxZ) / 2,
|
|
809
|
+
sizeX: maxX - minX,
|
|
810
|
+
sizeY: maxY - minY,
|
|
811
|
+
sizeZ: maxZ - minZ
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Builds the material set for one STEP mesh and assigns face-color groups
|
|
817
|
+
* when the importer exposes them.
|
|
818
|
+
* @param {any} THREE
|
|
819
|
+
* @param {any} geometry
|
|
820
|
+
* @param {{ color?: number[] | null, indices?: number[], faceColors?: { first: number, last: number, color: number[] | null }[] }} meshPayload
|
|
821
|
+
* @returns {any[]}
|
|
822
|
+
*/
|
|
823
|
+
static #buildStepMeshMaterials(THREE, geometry, meshPayload) {
|
|
824
|
+
const defaultColor = PcbScene3dExternalModels.#resolveMeshColor(
|
|
825
|
+
THREE,
|
|
826
|
+
meshPayload?.color
|
|
827
|
+
)
|
|
828
|
+
const defaultMaterial = PcbScene3dExternalModels.#createStepMaterial(
|
|
829
|
+
THREE,
|
|
830
|
+
defaultColor
|
|
831
|
+
)
|
|
832
|
+
const faceColors = Array.isArray(meshPayload?.faceColors)
|
|
833
|
+
? meshPayload.faceColors.filter((faceColor) =>
|
|
834
|
+
PcbScene3dExternalModels.#isValidFaceColorRange(
|
|
835
|
+
faceColor,
|
|
836
|
+
meshPayload?.indices
|
|
837
|
+
)
|
|
838
|
+
)
|
|
839
|
+
: []
|
|
840
|
+
|
|
841
|
+
if (!faceColors.length) {
|
|
842
|
+
return [defaultMaterial]
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const materials = [defaultMaterial]
|
|
846
|
+
faceColors.forEach((faceColor) => {
|
|
847
|
+
const resolvedColor =
|
|
848
|
+
Array.isArray(faceColor?.color) && faceColor.color.length >= 3
|
|
849
|
+
? PcbScene3dExternalModels.#resolveMeshColor(
|
|
850
|
+
THREE,
|
|
851
|
+
faceColor.color
|
|
852
|
+
)
|
|
853
|
+
: defaultColor
|
|
854
|
+
|
|
855
|
+
materials.push(
|
|
856
|
+
PcbScene3dExternalModels.#createStepMaterial(
|
|
857
|
+
THREE,
|
|
858
|
+
resolvedColor
|
|
859
|
+
)
|
|
860
|
+
)
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
PcbScene3dExternalModels.#applyFaceColorGroups(
|
|
864
|
+
geometry,
|
|
865
|
+
meshPayload?.indices || [],
|
|
866
|
+
faceColors
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
return materials
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Creates one standard material for imported STEP geometry.
|
|
874
|
+
* @param {any} THREE
|
|
875
|
+
* @param {any} color
|
|
876
|
+
* @returns {any}
|
|
877
|
+
*/
|
|
878
|
+
static #createStepMaterial(THREE, color) {
|
|
879
|
+
return new THREE.MeshStandardMaterial({
|
|
880
|
+
color,
|
|
881
|
+
roughness: 0.56,
|
|
882
|
+
metalness: 0.14
|
|
883
|
+
})
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Applies grouped material ranges for face-colored STEP triangles.
|
|
888
|
+
* @param {any} geometry
|
|
889
|
+
* @param {number[]} indices
|
|
890
|
+
* @param {{ first: number, last: number }[]} faceColors
|
|
891
|
+
* @returns {void}
|
|
892
|
+
*/
|
|
893
|
+
static #applyFaceColorGroups(geometry, indices, faceColors) {
|
|
894
|
+
const triangleCount = Math.floor(
|
|
895
|
+
(Array.isArray(indices) ? indices.length : 0) / 3
|
|
896
|
+
)
|
|
897
|
+
let triangleIndex = 0
|
|
898
|
+
let faceColorIndex = 0
|
|
899
|
+
|
|
900
|
+
while (triangleIndex < triangleCount) {
|
|
901
|
+
const firstIndex = triangleIndex
|
|
902
|
+
let lastIndex = triangleCount
|
|
903
|
+
let materialIndex = 0
|
|
904
|
+
|
|
905
|
+
if (faceColorIndex < faceColors.length) {
|
|
906
|
+
const currentFaceColor = faceColors[faceColorIndex]
|
|
907
|
+
|
|
908
|
+
if (triangleIndex < currentFaceColor.first) {
|
|
909
|
+
lastIndex = currentFaceColor.first
|
|
910
|
+
} else {
|
|
911
|
+
lastIndex = Math.min(
|
|
912
|
+
currentFaceColor.last + 1,
|
|
913
|
+
triangleCount
|
|
914
|
+
)
|
|
915
|
+
materialIndex = faceColorIndex + 1
|
|
916
|
+
faceColorIndex += 1
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
geometry.addGroup(
|
|
921
|
+
firstIndex * 3,
|
|
922
|
+
Math.max(lastIndex - firstIndex, 0) * 3,
|
|
923
|
+
materialIndex
|
|
924
|
+
)
|
|
925
|
+
triangleIndex = lastIndex
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Returns true when one face-color range overlaps valid triangle indices.
|
|
931
|
+
* @param {{ first?: number, last?: number }} faceColor
|
|
932
|
+
* @param {number[] | undefined} indices
|
|
933
|
+
* @returns {boolean}
|
|
934
|
+
*/
|
|
935
|
+
static #isValidFaceColorRange(faceColor, indices) {
|
|
936
|
+
const first = Number(faceColor?.first)
|
|
937
|
+
const last = Number(faceColor?.last)
|
|
938
|
+
const triangleCount = Math.floor(
|
|
939
|
+
(Array.isArray(indices) ? indices.length : 0) / 3
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
return (
|
|
943
|
+
Number.isInteger(first) &&
|
|
944
|
+
Number.isInteger(last) &&
|
|
945
|
+
first >= 0 &&
|
|
946
|
+
last >= first &&
|
|
947
|
+
first < triangleCount
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Resolves one STEP mesh color into a Three-friendly color value.
|
|
953
|
+
* @param {any} THREE
|
|
954
|
+
* @param {number[] | null} color
|
|
955
|
+
* @returns {any}
|
|
956
|
+
*/
|
|
957
|
+
static #resolveMeshColor(THREE, color) {
|
|
958
|
+
if (!Array.isArray(color) || color.length < 3) {
|
|
959
|
+
return 0xc8c8c8
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return new THREE.Color(
|
|
963
|
+
Number(color[0] || 0),
|
|
964
|
+
Number(color[1] || 0),
|
|
965
|
+
Number(color[2] || 0)
|
|
966
|
+
)
|
|
967
|
+
}
|
|
968
|
+
}
|