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.
Files changed (65) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +15 -0
  3. package/CONTRIBUTING.md +14 -0
  4. package/LICENSE +19 -0
  5. package/LICENSES/AGPL-3.0-or-later.txt +235 -0
  6. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  7. package/LICENSES/LGPL-2.1-or-later.txt +176 -0
  8. package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
  9. package/NOTICE.md +36 -0
  10. package/README.md +128 -0
  11. package/REUSE.toml +16 -0
  12. package/docs/api.md +148 -0
  13. package/docs/circuitjson.md +190 -0
  14. package/docs/model-format.md +117 -0
  15. package/docs/testing.md +23 -0
  16. package/package.json +65 -0
  17. package/spec/library-scope.md +36 -0
  18. package/src/PcbModelArchiveExporter.mjs +320 -0
  19. package/src/PcbScene3dArcUtils.mjs +27 -0
  20. package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
  21. package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
  22. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
  23. package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
  24. package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
  25. package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
  26. package/src/PcbScene3dCameraRig.mjs +168 -0
  27. package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
  28. package/src/PcbScene3dController.mjs +956 -0
  29. package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
  30. package/src/PcbScene3dCopperFactory.mjs +559 -0
  31. package/src/PcbScene3dCopperTextFactory.mjs +534 -0
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
  33. package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
  34. package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
  35. package/src/PcbScene3dDrillPathFactory.mjs +362 -0
  36. package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
  37. package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
  38. package/src/PcbScene3dExternalModels.mjs +968 -0
  39. package/src/PcbScene3dFallbackVisibility.mjs +82 -0
  40. package/src/PcbScene3dInteractionHints.mjs +56 -0
  41. package/src/PcbScene3dMountRig.mjs +53 -0
  42. package/src/PcbScene3dOutlineBuilder.mjs +210 -0
  43. package/src/PcbScene3dPadFactory.mjs +553 -0
  44. package/src/PcbScene3dPresetState.mjs +48 -0
  45. package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
  46. package/src/PcbScene3dRuntime.mjs +996 -0
  47. package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
  48. package/src/PcbScene3dSelectionStyler.mjs +252 -0
  49. package/src/PcbScene3dShapePathFactory.mjs +220 -0
  50. package/src/PcbScene3dShellRenderer.mjs +131 -0
  51. package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
  52. package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
  53. package/src/PcbScene3dStepLoader.mjs +611 -0
  54. package/src/PcbScene3dStrokeFont.mjs +671 -0
  55. package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
  56. package/src/PcbScene3dText.mjs +99 -0
  57. package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
  58. package/src/PcbScene3dViaFactory.mjs +176 -0
  59. package/src/PcbScene3dViewCompensation.mjs +109 -0
  60. package/src/PcbScene3dViewScale.mjs +24 -0
  61. package/src/PcbScene3dViewportResize.mjs +35 -0
  62. package/src/PcbScene3dWorkerClient.mjs +123 -0
  63. package/src/index.mjs +1 -0
  64. package/src/scene3d.mjs +44 -0
  65. 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
+ }