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,762 @@
1
+ import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
2
+
3
+ const COMPONENT_COLOR = [0.55, 0.56, 0.58]
4
+
5
+ /**
6
+ * Builds component body and external model meshes for assembly exports.
7
+ */
8
+ export class PcbAssemblyComponentMeshBuilder {
9
+ /**
10
+ * Builds component meshes and component-model diagnostics.
11
+ * @param {{ components?: object[], externalPlacements?: object[] }} sceneDescription Prepared scene description.
12
+ * @param {{ modelMeshLoader?: (placement: object) => Promise<object | object[]>, includeModels?: boolean, renderFallbackBodies?: boolean }} [options] Build options.
13
+ * @param {{ advance?: (units: number, message: string) => Promise<void> } | null} [progress] Progress tracker.
14
+ * @returns {Promise<{ meshes: object[], diagnostics: object[] }>}
15
+ */
16
+ static async build(sceneDescription, options = {}, progress = null) {
17
+ const diagnostics = []
18
+ const meshes =
19
+ PcbAssemblyComponentMeshBuilder.#buildFallbackComponentMeshes(
20
+ sceneDescription,
21
+ options
22
+ )
23
+ const loader =
24
+ typeof options.modelMeshLoader === 'function'
25
+ ? options.modelMeshLoader
26
+ : null
27
+
28
+ if (loader && options.includeModels !== false) {
29
+ meshes.push(
30
+ ...(await PcbAssemblyComponentMeshBuilder.#buildModelMeshes(
31
+ sceneDescription,
32
+ loader,
33
+ diagnostics,
34
+ progress
35
+ ))
36
+ )
37
+ }
38
+
39
+ if (options.includeModels !== false) {
40
+ PcbAssemblyComponentMeshBuilder.#appendMissingModelDiagnostics(
41
+ sceneDescription,
42
+ diagnostics
43
+ )
44
+ }
45
+
46
+ return { meshes, diagnostics }
47
+ }
48
+
49
+ /**
50
+ * Builds external component model meshes.
51
+ * @param {{ externalPlacements?: object[] }} sceneDescription Prepared scene description.
52
+ * @param {(placement: object) => Promise<object | object[]>} loader Model mesh loader.
53
+ * @param {object[]} diagnostics Mutable diagnostics list.
54
+ * @param {{ advance?: (units: number, message: string) => Promise<void> } | null} progress Progress tracker.
55
+ * @returns {Promise<object[]>}
56
+ */
57
+ static async #buildModelMeshes(
58
+ sceneDescription,
59
+ loader,
60
+ diagnostics,
61
+ progress
62
+ ) {
63
+ const meshes = []
64
+ const placements = PcbAssemblyComponentMeshBuilder.#array(
65
+ sceneDescription?.externalPlacements
66
+ ).filter((placement) => placement?.externalModel)
67
+
68
+ for (
69
+ let placementIndex = 0;
70
+ placementIndex < placements.length;
71
+ placementIndex += 1
72
+ ) {
73
+ const placement = placements[placementIndex]
74
+
75
+ try {
76
+ const loaded = await loader(placement)
77
+ const loadedMeshes = Array.isArray(loaded) ? loaded : [loaded]
78
+ loadedMeshes.filter(Boolean).forEach((mesh, index) => {
79
+ meshes.push(
80
+ PcbAssemblyComponentMeshBuilder.#componentModelMesh(
81
+ mesh,
82
+ placement,
83
+ loadedMeshes.length,
84
+ index
85
+ )
86
+ )
87
+ })
88
+ PcbAssemblyComponentMeshBuilder.#appendModelDiagnostic(
89
+ placement,
90
+ diagnostics
91
+ )
92
+ } catch (error) {
93
+ diagnostics.push(
94
+ PcbAssemblyComponentMeshBuilder.#diagnostic(
95
+ 'warning',
96
+ 'component_model_conversion_failed',
97
+ 'Could not convert 3D model for ' +
98
+ String(placement?.designator || 'component') +
99
+ ': ' +
100
+ String(error?.message || error)
101
+ )
102
+ )
103
+ }
104
+ await progress?.advance?.(
105
+ 1,
106
+ 'Loading component models ' +
107
+ (placementIndex + 1) +
108
+ '/' +
109
+ placements.length
110
+ )
111
+ }
112
+
113
+ return meshes
114
+ }
115
+
116
+ /**
117
+ * Prepares and places one loaded component model mesh.
118
+ * @param {object} mesh Loaded mesh.
119
+ * @param {object} placement Component placement.
120
+ * @param {number} meshCount Number of loaded meshes for this placement.
121
+ * @param {number} meshIndex Loaded mesh index.
122
+ * @returns {object}
123
+ */
124
+ static #componentModelMesh(mesh, placement, meshCount, meshIndex) {
125
+ const designator = PcbAssemblyMeshUtils.safeName(
126
+ placement?.designator || 'component'
127
+ )
128
+ const prepared =
129
+ PcbAssemblyComponentMeshBuilder.#prepareLoadedComponentMesh(
130
+ mesh,
131
+ placement
132
+ )
133
+
134
+ return PcbAssemblyMeshUtils.transformMesh(
135
+ {
136
+ ...prepared,
137
+ name:
138
+ 'component-' +
139
+ designator +
140
+ (meshCount > 1 ? '-' + (meshIndex + 1) : ''),
141
+ color: prepared.color || COMPONENT_COLOR,
142
+ ...PcbAssemblyComponentMeshBuilder.#placementOpacity(
143
+ placement,
144
+ prepared
145
+ )
146
+ },
147
+ {
148
+ ...placement,
149
+ modelTransform: null
150
+ }
151
+ )
152
+ }
153
+
154
+ /**
155
+ * Builds procedural component bodies for components without loaded models.
156
+ * @param {{ components?: object[], externalPlacements?: object[] }} sceneDescription Prepared scene description.
157
+ * @param {{ includeModels?: boolean, renderFallbackBodies?: boolean }} options Build options.
158
+ * @returns {object[]}
159
+ */
160
+ static #buildFallbackComponentMeshes(sceneDescription, options) {
161
+ if (options.renderFallbackBodies === false) {
162
+ return []
163
+ }
164
+
165
+ const externalDesignators = new Set(
166
+ PcbAssemblyComponentMeshBuilder.#array(
167
+ sceneDescription?.externalPlacements
168
+ )
169
+ .filter((placement) => placement?.externalModel)
170
+ .map((placement) => String(placement?.designator || '').trim())
171
+ .filter(Boolean)
172
+ )
173
+ const includeModels = options.includeModels !== false
174
+
175
+ return PcbAssemblyComponentMeshBuilder.#array(
176
+ sceneDescription?.components
177
+ )
178
+ .filter((component) =>
179
+ PcbAssemblyComponentMeshBuilder.#shouldBuildFallbackBody(
180
+ component,
181
+ externalDesignators,
182
+ includeModels
183
+ )
184
+ )
185
+ .map((component, index) =>
186
+ PcbAssemblyMeshUtils.transformMesh(
187
+ {
188
+ ...PcbAssemblyMeshUtils.box(
189
+ 'component-' +
190
+ PcbAssemblyMeshUtils.safeName(
191
+ component?.designator ||
192
+ 'fallback-' + (index + 1)
193
+ ) +
194
+ '-body',
195
+ {
196
+ width:
197
+ Number(component?.body?.sizeMil?.width) ||
198
+ 20,
199
+ depth:
200
+ Number(component?.body?.sizeMil?.depth) ||
201
+ 20,
202
+ height:
203
+ Number(component?.body?.sizeMil?.height) ||
204
+ 10,
205
+ color: COMPONENT_COLOR
206
+ }
207
+ ),
208
+ ...PcbAssemblyComponentMeshBuilder.#placementOpacity(
209
+ component,
210
+ {}
211
+ )
212
+ },
213
+ component
214
+ )
215
+ )
216
+ }
217
+
218
+ /**
219
+ * Returns whether a procedural body should be emitted for one component.
220
+ * @param {object} component Component scene entry.
221
+ * @param {Set<string>} externalDesignators Designators with external placements.
222
+ * @param {boolean} includeModels Whether model export is enabled.
223
+ * @returns {boolean}
224
+ */
225
+ static #shouldBuildFallbackBody(
226
+ component,
227
+ externalDesignators,
228
+ includeModels
229
+ ) {
230
+ if (!component || component.renderFallbackBody === false) {
231
+ return false
232
+ }
233
+
234
+ const designator = String(component?.designator || '').trim()
235
+ if (
236
+ includeModels &&
237
+ (component?.externalModel ||
238
+ (designator && externalDesignators.has(designator)))
239
+ ) {
240
+ return false
241
+ }
242
+
243
+ return Boolean(component?.body?.sizeMil)
244
+ }
245
+
246
+ /**
247
+ * Applies model-local transforms that need whole-mesh context.
248
+ * @param {object} mesh Loaded component mesh.
249
+ * @param {object} placement Component placement.
250
+ * @returns {object}
251
+ */
252
+ static #prepareLoadedComponentMesh(mesh, placement) {
253
+ const modelTransform = placement?.modelTransform || {}
254
+ let prepared = PcbAssemblyComponentMeshBuilder.#cloneMesh(mesh)
255
+
256
+ prepared = PcbAssemblyComponentMeshBuilder.#scaleMesh(
257
+ prepared,
258
+ PcbAssemblyComponentMeshBuilder.#modelScale(modelTransform)
259
+ )
260
+ prepared = PcbAssemblyComponentMeshBuilder.#rotateMesh(
261
+ prepared,
262
+ modelTransform?.rotationDeg || {}
263
+ )
264
+ prepared = PcbAssemblyComponentMeshBuilder.#applyBoardNormalDirection(
265
+ prepared,
266
+ modelTransform?.boardNormalDirection
267
+ )
268
+ prepared = PcbAssemblyComponentMeshBuilder.#applyModelOrigin(
269
+ prepared,
270
+ modelTransform
271
+ )
272
+ prepared = PcbAssemblyComponentMeshBuilder.#fitMeshToTarget(
273
+ prepared,
274
+ modelTransform
275
+ )
276
+
277
+ return PcbAssemblyComponentMeshBuilder.#translateMesh3d(
278
+ prepared,
279
+ PcbAssemblyComponentMeshBuilder.#modelOffset(modelTransform)
280
+ )
281
+ }
282
+
283
+ /**
284
+ * Clones mesh vertices so model-local transforms do not mutate loader output.
285
+ * @param {object} mesh Source mesh.
286
+ * @returns {object}
287
+ */
288
+ static #cloneMesh(mesh) {
289
+ return {
290
+ ...mesh,
291
+ vertices: PcbAssemblyComponentMeshBuilder.#array(
292
+ mesh?.vertices
293
+ ).map((vertex) => [
294
+ Number(vertex?.[0] || 0),
295
+ Number(vertex?.[1] || 0),
296
+ Number(vertex?.[2] || 0)
297
+ ])
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Applies model origin metadata.
303
+ * @param {object} mesh Mesh to transform.
304
+ * @param {object} modelTransform Model transform metadata.
305
+ * @returns {object}
306
+ */
307
+ static #applyModelOrigin(mesh, modelTransform) {
308
+ const origin =
309
+ PcbAssemblyComponentMeshBuilder.#explicitModelOrigin(
310
+ modelTransform
311
+ ) ||
312
+ PcbAssemblyComponentMeshBuilder.#alignedModelOrigin(
313
+ mesh,
314
+ modelTransform?.originAlignment
315
+ )
316
+
317
+ return origin
318
+ ? PcbAssemblyComponentMeshBuilder.#translateMesh3d(mesh, {
319
+ x: -origin.x,
320
+ y: -origin.y,
321
+ z: -origin.z
322
+ })
323
+ : mesh
324
+ }
325
+
326
+ /**
327
+ * Resolves an explicit model origin.
328
+ * @param {object} modelTransform Model transform metadata.
329
+ * @returns {{ x: number, y: number, z: number } | null}
330
+ */
331
+ static #explicitModelOrigin(modelTransform) {
332
+ const origin = modelTransform?.originPositionMil
333
+ if (!origin) {
334
+ return null
335
+ }
336
+
337
+ return {
338
+ x: Number(origin.x || 0),
339
+ y: Number(origin.y || 0),
340
+ z: Number(origin.z || 0)
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Resolves an inferred model origin from alignment metadata.
346
+ * @param {object} mesh Mesh data.
347
+ * @param {string | undefined} alignment Origin alignment.
348
+ * @returns {{ x: number, y: number, z: number } | null}
349
+ */
350
+ static #alignedModelOrigin(mesh, alignment) {
351
+ const value = String(alignment || '').toLowerCase()
352
+ if (!value) {
353
+ return null
354
+ }
355
+
356
+ const bounds = PcbAssemblyComponentMeshBuilder.#meshBounds3d(mesh)
357
+ if (!Number.isFinite(bounds.minX + bounds.maxX)) {
358
+ return null
359
+ }
360
+
361
+ const center = {
362
+ x: (bounds.minX + bounds.maxX) / 2,
363
+ y: (bounds.minY + bounds.maxY) / 2,
364
+ z: (bounds.minZ + bounds.maxZ) / 2
365
+ }
366
+ if (value === 'center') {
367
+ return center
368
+ }
369
+ if (value === 'center_of_component_on_board_surface') {
370
+ return {
371
+ x: center.x,
372
+ y: center.y,
373
+ z: bounds.minZ
374
+ }
375
+ }
376
+ return null
377
+ }
378
+
379
+ /**
380
+ * Fits a mesh to an optional target size.
381
+ * @param {object} mesh Mesh data.
382
+ * @param {object} modelTransform Model transform metadata.
383
+ * @returns {object}
384
+ */
385
+ static #fitMeshToTarget(mesh, modelTransform) {
386
+ const target = modelTransform?.targetSizeMil
387
+ if (!target) {
388
+ return mesh
389
+ }
390
+
391
+ const bounds = PcbAssemblyComponentMeshBuilder.#meshBounds3d(mesh)
392
+ const size = {
393
+ x: bounds.maxX - bounds.minX,
394
+ y: bounds.maxY - bounds.minY,
395
+ z: bounds.maxZ - bounds.minZ
396
+ }
397
+ const scale = {
398
+ x: size.x > 0 ? Number(target.x || 0) / size.x : 1,
399
+ y: size.y > 0 ? Number(target.y || 0) / size.y : 1,
400
+ z: size.z > 0 ? Number(target.z || 0) / size.z : 1
401
+ }
402
+
403
+ if (
404
+ !Number.isFinite(scale.x) ||
405
+ !Number.isFinite(scale.y) ||
406
+ !Number.isFinite(scale.z) ||
407
+ scale.x <= 0 ||
408
+ scale.y <= 0 ||
409
+ scale.z <= 0
410
+ ) {
411
+ return mesh
412
+ }
413
+
414
+ if (String(modelTransform?.objectFit || '') === 'fill_bounds') {
415
+ return PcbAssemblyComponentMeshBuilder.#scaleMesh(mesh, scale)
416
+ }
417
+
418
+ const uniformScale = Math.min(scale.x, scale.y, scale.z)
419
+ return PcbAssemblyComponentMeshBuilder.#scaleMesh(mesh, {
420
+ x: uniformScale,
421
+ y: uniformScale,
422
+ z: uniformScale
423
+ })
424
+ }
425
+
426
+ /**
427
+ * Applies a model board-normal direction.
428
+ * @param {object} mesh Mesh data.
429
+ * @param {string | undefined} direction Board normal direction.
430
+ * @returns {object}
431
+ */
432
+ static #applyBoardNormalDirection(mesh, direction) {
433
+ const rotation =
434
+ PcbAssemblyComponentMeshBuilder.#boardNormalRotation(direction)
435
+ return rotation
436
+ ? PcbAssemblyComponentMeshBuilder.#rotateMesh(mesh, rotation, false)
437
+ : mesh
438
+ }
439
+
440
+ /**
441
+ * Resolves a rotation that maps the model's board-normal axis to export Z.
442
+ * @param {string | undefined} direction Board normal direction.
443
+ * @returns {{ x?: number, y?: number, z?: number } | null}
444
+ */
445
+ static #boardNormalRotation(direction) {
446
+ switch (String(direction || '').toLowerCase()) {
447
+ case 'x+':
448
+ return { y: -90 }
449
+ case 'x-':
450
+ return { y: 90 }
451
+ case 'y+':
452
+ return { x: 90 }
453
+ case 'y-':
454
+ return { x: -90 }
455
+ case 'z-':
456
+ return { x: 180 }
457
+ default:
458
+ return null
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Applies per-axis mesh scale.
464
+ * @param {object} mesh Mesh data.
465
+ * @param {{ x?: number, y?: number, z?: number }} scale Scale factors.
466
+ * @returns {object}
467
+ */
468
+ static #scaleMesh(mesh, scale) {
469
+ return {
470
+ ...mesh,
471
+ vertices: PcbAssemblyComponentMeshBuilder.#array(
472
+ mesh?.vertices
473
+ ).map((vertex) => [
474
+ Number(vertex?.[0] || 0) * (Number(scale?.x) || 1),
475
+ Number(vertex?.[1] || 0) * (Number(scale?.y) || 1),
476
+ Number(vertex?.[2] || 0) * (Number(scale?.z) || 1)
477
+ ])
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Applies model-local rotation.
483
+ * @param {object} mesh Mesh data.
484
+ * @param {{ x?: number, y?: number, z?: number }} rotationDeg Rotation angles.
485
+ * @param {boolean} [invert] Whether to use legacy inverted rotation signs.
486
+ * @returns {object}
487
+ */
488
+ static #rotateMesh(mesh, rotationDeg, invert = true) {
489
+ const sign = invert ? -1 : 1
490
+ return {
491
+ ...mesh,
492
+ vertices: PcbAssemblyComponentMeshBuilder.#array(
493
+ mesh?.vertices
494
+ ).map((vertex) =>
495
+ PcbAssemblyComponentMeshBuilder.#rotatePoint(
496
+ {
497
+ x: Number(vertex?.[0] || 0),
498
+ y: Number(vertex?.[1] || 0),
499
+ z: Number(vertex?.[2] || 0)
500
+ },
501
+ {
502
+ x: sign * Number(rotationDeg?.x || 0),
503
+ y: sign * Number(rotationDeg?.y || 0),
504
+ z: sign * Number(rotationDeg?.z || 0)
505
+ }
506
+ )
507
+ )
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Rotates one point around X, Y, then Z.
513
+ * @param {{ x: number, y: number, z: number }} point Source point.
514
+ * @param {{ x?: number, y?: number, z?: number }} rotationDeg Rotation angles.
515
+ * @returns {number[]}
516
+ */
517
+ static #rotatePoint(point, rotationDeg) {
518
+ const afterX = PcbAssemblyComponentMeshBuilder.#rotatePointX(
519
+ point,
520
+ Number(rotationDeg.x || 0)
521
+ )
522
+ const afterY = PcbAssemblyComponentMeshBuilder.#rotatePointY(
523
+ afterX,
524
+ Number(rotationDeg.y || 0)
525
+ )
526
+ const afterZ = PcbAssemblyComponentMeshBuilder.#rotatePointZ(
527
+ afterY,
528
+ Number(rotationDeg.z || 0)
529
+ )
530
+ return [afterZ.x, afterZ.y, afterZ.z]
531
+ }
532
+
533
+ /**
534
+ * Rotates a point around X.
535
+ * @param {{ x: number, y: number, z: number }} point Source point.
536
+ * @param {number} angleDeg Angle in degrees.
537
+ * @returns {{ x: number, y: number, z: number }}
538
+ */
539
+ static #rotatePointX(point, angleDeg) {
540
+ const angle = (angleDeg * Math.PI) / 180
541
+ const cos = Math.cos(angle)
542
+ const sin = Math.sin(angle)
543
+ return {
544
+ x: point.x,
545
+ y: point.y * cos - point.z * sin,
546
+ z: point.y * sin + point.z * cos
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Rotates a point around Y.
552
+ * @param {{ x: number, y: number, z: number }} point Source point.
553
+ * @param {number} angleDeg Angle in degrees.
554
+ * @returns {{ x: number, y: number, z: number }}
555
+ */
556
+ static #rotatePointY(point, angleDeg) {
557
+ const angle = (angleDeg * Math.PI) / 180
558
+ const cos = Math.cos(angle)
559
+ const sin = Math.sin(angle)
560
+ return {
561
+ x: point.x * cos + point.z * sin,
562
+ y: point.y,
563
+ z: -point.x * sin + point.z * cos
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Rotates a point around Z.
569
+ * @param {{ x: number, y: number, z: number }} point Source point.
570
+ * @param {number} angleDeg Angle in degrees.
571
+ * @returns {{ x: number, y: number, z: number }}
572
+ */
573
+ static #rotatePointZ(point, angleDeg) {
574
+ const angle = (angleDeg * Math.PI) / 180
575
+ const cos = Math.cos(angle)
576
+ const sin = Math.sin(angle)
577
+ return {
578
+ x: point.x * cos - point.y * sin,
579
+ y: point.x * sin + point.y * cos,
580
+ z: point.z
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Translates mesh vertices in 3D.
586
+ * @param {object} mesh Mesh data.
587
+ * @param {{ x?: number, y?: number, z?: number }} offset Translation.
588
+ * @returns {object}
589
+ */
590
+ static #translateMesh3d(mesh, offset) {
591
+ return {
592
+ ...mesh,
593
+ vertices: PcbAssemblyComponentMeshBuilder.#array(
594
+ mesh?.vertices
595
+ ).map((vertex) => [
596
+ Number(vertex?.[0] || 0) + Number(offset?.x || 0),
597
+ Number(vertex?.[1] || 0) + Number(offset?.y || 0),
598
+ Number(vertex?.[2] || 0) + Number(offset?.z || 0)
599
+ ])
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Computes 3D mesh bounds.
605
+ * @param {object} mesh Mesh data.
606
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number, minZ: number, maxZ: number }}
607
+ */
608
+ static #meshBounds3d(mesh) {
609
+ return PcbAssemblyComponentMeshBuilder.#array(mesh?.vertices).reduce(
610
+ (bounds, vertex) => ({
611
+ minX: Math.min(bounds.minX, Number(vertex?.[0] || 0)),
612
+ maxX: Math.max(bounds.maxX, Number(vertex?.[0] || 0)),
613
+ minY: Math.min(bounds.minY, Number(vertex?.[1] || 0)),
614
+ maxY: Math.max(bounds.maxY, Number(vertex?.[1] || 0)),
615
+ minZ: Math.min(bounds.minZ, Number(vertex?.[2] || 0)),
616
+ maxZ: Math.max(bounds.maxZ, Number(vertex?.[2] || 0))
617
+ }),
618
+ {
619
+ minX: Infinity,
620
+ maxX: -Infinity,
621
+ minY: Infinity,
622
+ maxY: -Infinity,
623
+ minZ: Infinity,
624
+ maxZ: -Infinity
625
+ }
626
+ )
627
+ }
628
+
629
+ /**
630
+ * Resolves model-local scale.
631
+ * @param {object} modelTransform Model transform metadata.
632
+ * @returns {{ x: number, y: number, z: number }}
633
+ */
634
+ static #modelScale(modelTransform) {
635
+ const scale = modelTransform?.scale || {}
636
+ return {
637
+ x: Number(scale.x ?? 1) || 1,
638
+ y: Number(scale.y ?? 1) || 1,
639
+ z: Number(scale.z ?? 1) || 1
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Resolves model-local offset.
645
+ * @param {object} modelTransform Model transform metadata.
646
+ * @returns {{ x: number, y: number, z: number }}
647
+ */
648
+ static #modelOffset(modelTransform) {
649
+ const offset = modelTransform?.offsetMil || {}
650
+ return {
651
+ x: Number(offset.x ?? modelTransform?.dxMil ?? 0),
652
+ y: Number(offset.y ?? modelTransform?.dyMil ?? 0),
653
+ z: Number(offset.z ?? modelTransform?.dzMil ?? 0)
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Resolves opacity metadata to preserve in exported materials.
659
+ * @param {object} placement Component placement or fallback component.
660
+ * @param {object} mesh Mesh metadata.
661
+ * @returns {{ opacity?: number }}
662
+ */
663
+ static #placementOpacity(placement, mesh) {
664
+ const opacity = Number(mesh?.opacity ?? placement?.bodyOpacity)
665
+ return Number.isFinite(opacity) && opacity > 0 && opacity < 1
666
+ ? { opacity }
667
+ : {}
668
+ }
669
+
670
+ /**
671
+ * Adds a format-specific component model diagnostic.
672
+ * @param {object} placement Component placement.
673
+ * @param {object[]} diagnostics Mutable diagnostics list.
674
+ * @returns {void}
675
+ */
676
+ static #appendModelDiagnostic(placement, diagnostics) {
677
+ const format = String(
678
+ placement?.externalModel?.format || ''
679
+ ).toLowerCase()
680
+ if (format === 'wrl' || format === 'vrml') {
681
+ diagnostics.push(
682
+ PcbAssemblyComponentMeshBuilder.#diagnostic(
683
+ 'info',
684
+ 'component_wrl_faceted_step',
685
+ 'Converted WRL model for ' +
686
+ String(placement?.designator || 'component') +
687
+ ' as faceted geometry.'
688
+ )
689
+ )
690
+ return
691
+ }
692
+
693
+ if (format === 'step' || format === 'stp') {
694
+ diagnostics.push(
695
+ PcbAssemblyComponentMeshBuilder.#diagnostic(
696
+ 'info',
697
+ 'component_step_faceted_export',
698
+ 'Included STEP model for ' +
699
+ String(placement?.designator || 'component') +
700
+ ' through export mesh geometry.'
701
+ )
702
+ )
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Adds diagnostics for components without resolved external models.
708
+ * @param {{ components?: object[], externalPlacements?: object[] }} sceneDescription Scene description.
709
+ * @param {object[]} diagnostics Mutable diagnostics list.
710
+ * @returns {void}
711
+ */
712
+ static #appendMissingModelDiagnostics(sceneDescription, diagnostics) {
713
+ const placementDesignators = new Set(
714
+ PcbAssemblyComponentMeshBuilder.#array(
715
+ sceneDescription?.externalPlacements
716
+ )
717
+ .map((placement) => String(placement?.designator || '').trim())
718
+ .filter(Boolean)
719
+ )
720
+
721
+ PcbAssemblyComponentMeshBuilder.#array(
722
+ sceneDescription?.components
723
+ ).forEach((component) => {
724
+ const designator = String(component?.designator || '').trim()
725
+ if (
726
+ !designator ||
727
+ component?.externalModel ||
728
+ placementDesignators.has(designator)
729
+ ) {
730
+ return
731
+ }
732
+
733
+ diagnostics.push(
734
+ PcbAssemblyComponentMeshBuilder.#diagnostic(
735
+ 'warning',
736
+ 'component_model_missing',
737
+ 'No resolved 3D model was available for ' + designator + '.'
738
+ )
739
+ )
740
+ })
741
+ }
742
+
743
+ /**
744
+ * Creates a normalized diagnostic object.
745
+ * @param {string} severity Diagnostic severity.
746
+ * @param {string} code Diagnostic code.
747
+ * @param {string} message User-facing message.
748
+ * @returns {object}
749
+ */
750
+ static #diagnostic(severity, code, message) {
751
+ return { severity, code, message }
752
+ }
753
+
754
+ /**
755
+ * Normalizes a value to an array.
756
+ * @param {unknown} value Candidate value.
757
+ * @returns {any[]}
758
+ */
759
+ static #array(value) {
760
+ return Array.isArray(value) ? value : []
761
+ }
762
+ }