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,859 @@
1
+ import { PcbScene3dBoardMaterialPalette } from './PcbScene3dBoardMaterialPalette.mjs'
2
+
3
+ /**
4
+ * Applies display-only material adjustments to full-board assembly models.
5
+ */
6
+ export class PcbScene3dBoardAssemblyPresentation {
7
+ /** @type {number} */
8
+ static #BOARD_COVER_AREA_RATIO = 0.15
9
+
10
+ /** @type {number} */
11
+ static #BOARD_COVER_LONG_SPAN_RATIO = 0.25
12
+
13
+ /** @type {number} */
14
+ static #COARSE_COVER_TRIANGLE_LIMIT = 36
15
+
16
+ /**
17
+ * Hides coarse board/shield surfaces in full-board assemblies so the
18
+ * PCB-derived substrate, copper, and silkscreen remain the visual source.
19
+ * @param {any} modelGroup Loaded board assembly group.
20
+ * @param {{ widthMil?: number, heightMil?: number, thicknessMil?: number } | null | undefined} board Board dimensions.
21
+ * @returns {void}
22
+ */
23
+ static apply(modelGroup, board) {
24
+ const meshRecords =
25
+ PcbScene3dBoardAssemblyPresentation.#collectMeshRecords(modelGroup)
26
+ const boardBounds =
27
+ PcbScene3dBoardAssemblyPresentation.#resolveBoardEnvelope(
28
+ meshRecords,
29
+ board
30
+ )
31
+ const surfaceColor =
32
+ PcbScene3dBoardAssemblyPresentation.#resolveSurfaceColor(board)
33
+ const edgeColor =
34
+ PcbScene3dBoardAssemblyPresentation.#resolveEdgeColor(board)
35
+ const importedSurfaceColor =
36
+ PcbScene3dBoardAssemblyPresentation.#resolveImportedSurfaceColor(
37
+ meshRecords
38
+ )
39
+
40
+ if (Number.isInteger(importedSurfaceColor)) {
41
+ modelGroup.userData = modelGroup.userData || {}
42
+ modelGroup.userData.scene3dBoardAssemblySurfaceColor =
43
+ importedSurfaceColor
44
+ }
45
+
46
+ meshRecords.forEach((record) =>
47
+ PcbScene3dBoardAssemblyPresentation.#applyMeshPresentation(
48
+ record,
49
+ boardBounds,
50
+ surfaceColor,
51
+ edgeColor
52
+ )
53
+ )
54
+ PcbScene3dBoardAssemblyPresentation.#centerSubstrateOnBoardPlane(
55
+ modelGroup,
56
+ boardBounds
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Resolves assembly meshes into presentation records.
62
+ * @param {any} modelGroup Loaded board assembly group.
63
+ * @returns {{ object: any, meshBounds: { minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number, sizeX: number, sizeY: number, sizeZ: number }, materialKind: string, triangleCount: number }[]}
64
+ */
65
+ static #collectMeshRecords(modelGroup) {
66
+ const records = []
67
+
68
+ PcbScene3dBoardAssemblyPresentation.#traverseModelObjects(
69
+ modelGroup,
70
+ (object) => {
71
+ if (!object?.material) {
72
+ return
73
+ }
74
+
75
+ const meshBounds =
76
+ PcbScene3dBoardAssemblyPresentation.#resolveMeshBoundsMil(
77
+ object
78
+ )
79
+ if (!meshBounds) {
80
+ return
81
+ }
82
+
83
+ records.push({
84
+ object,
85
+ meshBounds,
86
+ materialKind:
87
+ PcbScene3dBoardAssemblyPresentation.#resolveMaterialKind(
88
+ object.material
89
+ ),
90
+ triangleCount:
91
+ PcbScene3dBoardAssemblyPresentation.#resolveTriangleCount(
92
+ object
93
+ )
94
+ })
95
+ }
96
+ )
97
+
98
+ return records
99
+ }
100
+
101
+ /**
102
+ * Applies display treatment to one assembly mesh record.
103
+ * @param {{ object: any, meshBounds: { minX: number, minY: number, maxX: number, maxY: number }, materialKind: string, triangleCount: number }} record Mesh record.
104
+ * @param {{ minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number, widthMil: number, heightMil: number, areaMil: number } | null} boardBounds Board envelope.
105
+ * @param {number} surfaceColor Board substrate color.
106
+ * @param {number} edgeColor Board edge color.
107
+ * @returns {void}
108
+ */
109
+ static #applyMeshPresentation(
110
+ record,
111
+ boardBounds,
112
+ surfaceColor,
113
+ edgeColor
114
+ ) {
115
+ if (record?.materialKind === 'board') {
116
+ PcbScene3dBoardAssemblyPresentation.#applyBoardSubstrateMaterial(
117
+ record.object,
118
+ surfaceColor,
119
+ edgeColor
120
+ )
121
+ record.object.visible = false
122
+ return
123
+ }
124
+
125
+ if (
126
+ record?.materialKind === 'neutral' &&
127
+ PcbScene3dBoardAssemblyPresentation.#isLargeBoardCoverMesh(
128
+ record,
129
+ boardBounds
130
+ )
131
+ ) {
132
+ record.object.visible = false
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Applies solder-mask and substrate-edge colors to an imported board mesh.
138
+ * @param {any} object Board-substrate mesh.
139
+ * @param {number} surfaceColor Solder-mask face color.
140
+ * @param {number} edgeColor Substrate edge color.
141
+ * @returns {void}
142
+ */
143
+ static #applyBoardSubstrateMaterial(object, surfaceColor, edgeColor) {
144
+ if (
145
+ PcbScene3dBoardAssemblyPresentation.#applyBoardFaceGroups(
146
+ object,
147
+ surfaceColor,
148
+ edgeColor
149
+ )
150
+ ) {
151
+ return
152
+ }
153
+
154
+ PcbScene3dBoardAssemblyPresentation.#applyMaterialColor(
155
+ object?.material,
156
+ surfaceColor
157
+ )
158
+ }
159
+
160
+ /**
161
+ * Splits board substrate faces into mask and edge material groups.
162
+ * @param {any} object Board-substrate mesh.
163
+ * @param {number} surfaceColor Solder-mask face color.
164
+ * @param {number} edgeColor Substrate edge color.
165
+ * @returns {boolean}
166
+ */
167
+ static #applyBoardFaceGroups(object, surfaceColor, edgeColor) {
168
+ const geometry = object?.geometry
169
+ const positions = geometry?.attributes?.position?.array
170
+ const triangleCount =
171
+ PcbScene3dBoardAssemblyPresentation.#resolveGeometryTriangleCount(
172
+ geometry
173
+ )
174
+
175
+ if (!positions?.length || triangleCount <= 0) {
176
+ return false
177
+ }
178
+
179
+ const sourceMaterial = Array.isArray(object.material)
180
+ ? object.material[0]
181
+ : object.material
182
+ const surfaceMaterial =
183
+ PcbScene3dBoardAssemblyPresentation.#cloneMaterial(sourceMaterial)
184
+ const edgeMaterial =
185
+ PcbScene3dBoardAssemblyPresentation.#cloneMaterial(sourceMaterial)
186
+
187
+ if (!surfaceMaterial || !edgeMaterial) {
188
+ return false
189
+ }
190
+
191
+ PcbScene3dBoardAssemblyPresentation.#applyMaterialColor(
192
+ surfaceMaterial,
193
+ surfaceColor
194
+ )
195
+ PcbScene3dBoardAssemblyPresentation.#applyMaterialColor(
196
+ edgeMaterial,
197
+ edgeColor
198
+ )
199
+ object.material = [surfaceMaterial, edgeMaterial]
200
+ PcbScene3dBoardAssemblyPresentation.#clearGeometryGroups(geometry)
201
+ PcbScene3dBoardAssemblyPresentation.#appendBoardFaceGroups(
202
+ geometry,
203
+ positions,
204
+ triangleCount
205
+ )
206
+ return true
207
+ }
208
+
209
+ /**
210
+ * Appends contiguous board face material ranges.
211
+ * @param {any} geometry Buffer geometry.
212
+ * @param {ArrayLike<number>} positions Position buffer.
213
+ * @param {number} triangleCount Geometry triangle count.
214
+ * @returns {void}
215
+ */
216
+ static #appendBoardFaceGroups(geometry, positions, triangleCount) {
217
+ let runStart = 0
218
+ let runMaterialIndex =
219
+ PcbScene3dBoardAssemblyPresentation.#resolveTriangleMaterialIndex(
220
+ geometry,
221
+ positions,
222
+ 0
223
+ )
224
+
225
+ for (let index = 1; index < triangleCount; index += 1) {
226
+ const materialIndex =
227
+ PcbScene3dBoardAssemblyPresentation.#resolveTriangleMaterialIndex(
228
+ geometry,
229
+ positions,
230
+ index
231
+ )
232
+
233
+ if (materialIndex === runMaterialIndex) {
234
+ continue
235
+ }
236
+
237
+ PcbScene3dBoardAssemblyPresentation.#appendGeometryGroup(
238
+ geometry,
239
+ runStart,
240
+ index,
241
+ runMaterialIndex
242
+ )
243
+ runStart = index
244
+ runMaterialIndex = materialIndex
245
+ }
246
+
247
+ PcbScene3dBoardAssemblyPresentation.#appendGeometryGroup(
248
+ geometry,
249
+ runStart,
250
+ triangleCount,
251
+ runMaterialIndex
252
+ )
253
+ }
254
+
255
+ /**
256
+ * Resolves a board triangle material index from its normal.
257
+ * @param {any} geometry Buffer geometry.
258
+ * @param {ArrayLike<number>} positions Position buffer.
259
+ * @param {number} triangleIndex Triangle index.
260
+ * @returns {0 | 1}
261
+ */
262
+ static #resolveTriangleMaterialIndex(geometry, positions, triangleIndex) {
263
+ const points = [0, 1, 2].map((cornerIndex) =>
264
+ PcbScene3dBoardAssemblyPresentation.#readTrianglePoint(
265
+ geometry,
266
+ positions,
267
+ triangleIndex,
268
+ cornerIndex
269
+ )
270
+ )
271
+ const zRatio =
272
+ PcbScene3dBoardAssemblyPresentation.#resolveNormalZRatio(points)
273
+
274
+ return zRatio >= 0.75 ? 0 : 1
275
+ }
276
+
277
+ /**
278
+ * Reads one triangle corner from indexed or unindexed geometry.
279
+ * @param {any} geometry Buffer geometry.
280
+ * @param {ArrayLike<number>} positions Position buffer.
281
+ * @param {number} triangleIndex Triangle index.
282
+ * @param {number} cornerIndex Corner index.
283
+ * @returns {{ x: number, y: number, z: number }}
284
+ */
285
+ static #readTrianglePoint(geometry, positions, triangleIndex, cornerIndex) {
286
+ const indices = geometry?.index?.array
287
+ const vertexIndex = indices?.length
288
+ ? Number(indices[triangleIndex * 3 + cornerIndex] || 0)
289
+ : triangleIndex * 3 + cornerIndex
290
+ const offset = vertexIndex * 3
291
+
292
+ return {
293
+ x: Number(positions[offset] || 0),
294
+ y: Number(positions[offset + 1] || 0),
295
+ z: Number(positions[offset + 2] || 0)
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Resolves how closely one triangle normal points along board Z.
301
+ * @param {{ x: number, y: number, z: number }[]} points Triangle points.
302
+ * @returns {number}
303
+ */
304
+ static #resolveNormalZRatio(points) {
305
+ const [a, b, c] = points
306
+ const ux = b.x - a.x
307
+ const uy = b.y - a.y
308
+ const uz = b.z - a.z
309
+ const vx = c.x - a.x
310
+ const vy = c.y - a.y
311
+ const vz = c.z - a.z
312
+ const nx = uy * vz - uz * vy
313
+ const ny = uz * vx - ux * vz
314
+ const nz = ux * vy - uy * vx
315
+ const length = Math.hypot(nx, ny, nz)
316
+
317
+ return length > 0 ? Math.abs(nz) / length : 1
318
+ }
319
+
320
+ /**
321
+ * Resolves one geometry triangle count.
322
+ * @param {any} geometry Buffer geometry.
323
+ * @returns {number}
324
+ */
325
+ static #resolveGeometryTriangleCount(geometry) {
326
+ const indexCount =
327
+ Number(geometry?.index?.count) ||
328
+ Number(geometry?.index?.array?.length) ||
329
+ 0
330
+
331
+ if (indexCount > 0) {
332
+ return Math.floor(indexCount / 3)
333
+ }
334
+
335
+ return Math.floor(
336
+ Number(geometry?.attributes?.position?.count || 0) / 3
337
+ )
338
+ }
339
+
340
+ /**
341
+ * Clears existing material groups from one geometry.
342
+ * @param {any} geometry Buffer geometry.
343
+ * @returns {void}
344
+ */
345
+ static #clearGeometryGroups(geometry) {
346
+ if (typeof geometry?.clearGroups === 'function') {
347
+ geometry.clearGroups()
348
+ return
349
+ }
350
+
351
+ if (Array.isArray(geometry?.groups)) {
352
+ geometry.groups.length = 0
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Appends one triangle material group.
358
+ * @param {any} geometry Buffer geometry.
359
+ * @param {number} startTriangle First triangle index.
360
+ * @param {number} endTriangle Exclusive end triangle index.
361
+ * @param {number} materialIndex Material index.
362
+ * @returns {void}
363
+ */
364
+ static #appendGeometryGroup(
365
+ geometry,
366
+ startTriangle,
367
+ endTriangle,
368
+ materialIndex
369
+ ) {
370
+ const count = Math.max(endTriangle - startTriangle, 0) * 3
371
+ if (count <= 0) {
372
+ return
373
+ }
374
+
375
+ if (typeof geometry?.addGroup === 'function') {
376
+ geometry.addGroup(startTriangle * 3, count, materialIndex)
377
+ } else if (Array.isArray(geometry?.groups)) {
378
+ geometry.groups.push({
379
+ start: startTriangle * 3,
380
+ count,
381
+ materialIndex
382
+ })
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Clones one material without sharing mutable color state.
388
+ * @param {any} material Source material.
389
+ * @returns {any | null}
390
+ */
391
+ static #cloneMaterial(material) {
392
+ if (!material) {
393
+ return null
394
+ }
395
+
396
+ if (typeof material.clone === 'function') {
397
+ return material.clone()
398
+ }
399
+
400
+ return {
401
+ ...material,
402
+ color:
403
+ typeof material.color?.clone === 'function'
404
+ ? material.color.clone()
405
+ : material.color
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Resolves the app-level substrate color for board assembly meshes.
411
+ * @param {{ surfaceColor?: number } | null | undefined} board Board dimensions.
412
+ * @returns {number}
413
+ */
414
+ static #resolveSurfaceColor(board) {
415
+ return PcbScene3dBoardMaterialPalette.resolveSurfaceColor(board, {
416
+ hasBoardAssemblyModel: true
417
+ })
418
+ }
419
+
420
+ /**
421
+ * Resolves the app-level substrate edge color for board assembly meshes.
422
+ * @param {{ edgeColor?: number } | null | undefined} board Board dimensions.
423
+ * @returns {number}
424
+ */
425
+ static #resolveEdgeColor(board) {
426
+ return Number.isInteger(board?.edgeColor) ? board.edgeColor : 0xc9ca78
427
+ }
428
+
429
+ /**
430
+ * Applies display treatment to board substrate material.
431
+ * @param {any | any[]} material Material or material list.
432
+ * @param {number} color Hex color.
433
+ * @returns {void}
434
+ */
435
+ static #applyMaterialColor(material, color) {
436
+ ;(Array.isArray(material) ? material : [material])
437
+ .filter(Boolean)
438
+ .forEach((entry) => {
439
+ if (typeof entry.color?.setHex === 'function') {
440
+ entry.color.setHex(color)
441
+ } else {
442
+ entry.color = color
443
+ }
444
+ entry.needsUpdate = true
445
+ })
446
+ }
447
+
448
+ /**
449
+ * Resolves the imported board-substrate color before display overrides.
450
+ * @param {{ materialKind: string, object: { material?: any | any[] } }[]} meshRecords Mesh records.
451
+ * @returns {number | null}
452
+ */
453
+ static #resolveImportedSurfaceColor(meshRecords) {
454
+ const substrateRecord = (
455
+ Array.isArray(meshRecords) ? meshRecords : []
456
+ ).find((record) => record?.materialKind === 'board')
457
+
458
+ return PcbScene3dBoardAssemblyPresentation.#resolveMaterialColor(
459
+ substrateRecord?.object?.material
460
+ )
461
+ }
462
+
463
+ /**
464
+ * Resolves a material color as a numeric hex value.
465
+ * @param {any | any[]} material Material or material list.
466
+ * @returns {number | null}
467
+ */
468
+ static #resolveMaterialColor(material) {
469
+ for (const entry of Array.isArray(material) ? material : [material]) {
470
+ if (typeof entry?.color?.getHex === 'function') {
471
+ return entry.color.getHex()
472
+ }
473
+
474
+ const numericColor = Number(entry?.color)
475
+ if (Number.isInteger(numericColor)) {
476
+ return numericColor
477
+ }
478
+ }
479
+
480
+ return null
481
+ }
482
+
483
+ /**
484
+ * Centers an imported substrate's Z span on the app board plane so ECAD
485
+ * copper and silkscreen overlays sit on the physical board faces.
486
+ * @param {{ position?: { z?: number }, userData?: Record<string, any> }} modelGroup Loaded board assembly group.
487
+ * @param {{ minZ?: number, maxZ?: number } | null} boardBounds Board envelope.
488
+ * @returns {void}
489
+ */
490
+ static #centerSubstrateOnBoardPlane(modelGroup, boardBounds) {
491
+ const minZ = Number(boardBounds?.minZ)
492
+ const maxZ = Number(boardBounds?.maxZ)
493
+
494
+ if (
495
+ !modelGroup?.position ||
496
+ !Number.isFinite(minZ) ||
497
+ !Number.isFinite(maxZ) ||
498
+ !(maxZ > minZ)
499
+ ) {
500
+ return
501
+ }
502
+
503
+ const zOffset = -((minZ + maxZ) / 2)
504
+ modelGroup.position.z = Number(modelGroup.position.z || 0) + zOffset
505
+ modelGroup.userData = modelGroup.userData || {}
506
+ modelGroup.userData.scene3dBoardAssemblyZOffsetMil = zOffset
507
+ }
508
+
509
+ /**
510
+ * Resolves one mesh material into a coarse presentation category.
511
+ * @param {any | any[]} material Source material.
512
+ * @returns {string}
513
+ */
514
+ static #resolveMaterialKind(material) {
515
+ const rgb = PcbScene3dBoardAssemblyPresentation.#resolveMaterialRgb(
516
+ (Array.isArray(material) ? material[0] : material)?.color
517
+ )
518
+ if (!rgb) {
519
+ return ''
520
+ }
521
+
522
+ const min = Math.min(rgb.r, rgb.g, rgb.b)
523
+ const max = Math.max(rgb.r, rgb.g, rgb.b)
524
+ const average = (rgb.r + rgb.g + rgb.b) / 3
525
+ const isNeutral = max - min <= 0.08
526
+ const isBoardGreen = rgb.g > rgb.r * 1.35 && rgb.g > rgb.b * 1.35
527
+
528
+ if (isBoardGreen) {
529
+ return 'board'
530
+ }
531
+
532
+ if (isNeutral && average >= 0.18 && average <= 0.82) {
533
+ return 'neutral'
534
+ }
535
+
536
+ return ''
537
+ }
538
+
539
+ /**
540
+ * Returns true for coarse board-cover surfaces that obscure PCB detail.
541
+ * @param {{ meshBounds: { minX: number, minY: number, maxX: number, maxY: number, sizeX: number, sizeY: number }, triangleCount: number }} record Mesh record.
542
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number, areaMil: number } | null} boardBounds Board envelope.
543
+ * @returns {boolean}
544
+ */
545
+ static #isLargeBoardCoverMesh(record, boardBounds) {
546
+ if (
547
+ !PcbScene3dBoardAssemblyPresentation.#isMostlyInsideBoard(
548
+ record?.meshBounds,
549
+ boardBounds
550
+ )
551
+ ) {
552
+ return false
553
+ }
554
+
555
+ const meshArea =
556
+ Number(record.meshBounds.sizeX || 0) *
557
+ Number(record.meshBounds.sizeY || 0)
558
+ const boardArea = Number(boardBounds?.areaMil || 0)
559
+
560
+ return (
561
+ PcbScene3dBoardAssemblyPresentation.#hasLargeBoardCoverArea(
562
+ meshArea,
563
+ boardArea
564
+ ) ||
565
+ PcbScene3dBoardAssemblyPresentation.#isCoarseLongBoardCoverMesh(
566
+ record,
567
+ boardBounds
568
+ )
569
+ )
570
+ }
571
+
572
+ /**
573
+ * Returns true when one footprint covers a broad board area.
574
+ * @param {number} meshArea Mesh footprint area.
575
+ * @param {number} boardArea Board footprint area.
576
+ * @returns {boolean}
577
+ */
578
+ static #hasLargeBoardCoverArea(meshArea, boardArea) {
579
+ return (
580
+ boardArea > 0 &&
581
+ meshArea / boardArea >=
582
+ PcbScene3dBoardAssemblyPresentation.#BOARD_COVER_AREA_RATIO
583
+ )
584
+ }
585
+
586
+ /**
587
+ * Returns true for thin low-detail shield walls that span much of a board.
588
+ * @param {{ meshBounds: { sizeX: number, sizeY: number }, triangleCount: number }} record Mesh record.
589
+ * @param {{ widthMil: number, heightMil: number }} boardBounds Board envelope.
590
+ * @returns {boolean}
591
+ */
592
+ static #isCoarseLongBoardCoverMesh(record, boardBounds) {
593
+ const triangleCount = Number(record?.triangleCount || 0)
594
+ if (
595
+ triangleCount <= 0 ||
596
+ triangleCount >
597
+ PcbScene3dBoardAssemblyPresentation.#COARSE_COVER_TRIANGLE_LIMIT
598
+ ) {
599
+ return false
600
+ }
601
+
602
+ const xSpanRatio =
603
+ Number(record?.meshBounds?.sizeX || 0) /
604
+ Math.max(Number(boardBounds?.widthMil || 0), 1)
605
+ const ySpanRatio =
606
+ Number(record?.meshBounds?.sizeY || 0) /
607
+ Math.max(Number(boardBounds?.heightMil || 0), 1)
608
+
609
+ return (
610
+ xSpanRatio >=
611
+ PcbScene3dBoardAssemblyPresentation
612
+ .#BOARD_COVER_LONG_SPAN_RATIO ||
613
+ ySpanRatio >=
614
+ PcbScene3dBoardAssemblyPresentation.#BOARD_COVER_LONG_SPAN_RATIO
615
+ )
616
+ }
617
+
618
+ /**
619
+ * Resolves board dimensions into a bounded area descriptor.
620
+ * @param {{ widthMil?: number, heightMil?: number, thicknessMil?: number } | null | undefined} board Board dimensions.
621
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number, minZ: number, maxZ: number, areaMil: number } | null}
622
+ */
623
+ static #normalizeBoardBounds(board) {
624
+ const widthMil = Number(board?.widthMil || 0)
625
+ const heightMil = Number(board?.heightMil || 0)
626
+ const thicknessMil = Number(board?.thicknessMil || 0)
627
+
628
+ if (!(widthMil > 0) || !(heightMil > 0)) {
629
+ return null
630
+ }
631
+
632
+ return {
633
+ minX: 0,
634
+ minY: 0,
635
+ maxX: widthMil,
636
+ maxY: heightMil,
637
+ widthMil,
638
+ heightMil,
639
+ minZ: thicknessMil > 0 ? -thicknessMil / 2 : 0,
640
+ maxZ: thicknessMil > 0 ? thicknessMil / 2 : 0,
641
+ areaMil: widthMil * heightMil
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Resolves the board envelope from assembly substrate meshes when present.
647
+ * @param {{ meshBounds: { minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number }, materialKind: string }[]} meshRecords Mesh records.
648
+ * @param {{ widthMil?: number, heightMil?: number, thicknessMil?: number } | null | undefined} board Board dimensions.
649
+ * @returns {{ minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number, widthMil: number, heightMil: number, areaMil: number } | null}
650
+ */
651
+ static #resolveBoardEnvelope(meshRecords, board) {
652
+ const substrateBounds =
653
+ PcbScene3dBoardAssemblyPresentation.#unionBounds(
654
+ (Array.isArray(meshRecords) ? meshRecords : [])
655
+ .filter((record) => record.materialKind === 'board')
656
+ .map((record) => record.meshBounds)
657
+ )
658
+
659
+ if (substrateBounds) {
660
+ return substrateBounds
661
+ }
662
+
663
+ return PcbScene3dBoardAssemblyPresentation.#normalizeBoardBounds(board)
664
+ }
665
+
666
+ /**
667
+ * Builds one combined bounds descriptor.
668
+ * @param {{ minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number }[]} boundsList Bounds list.
669
+ * @returns {{ minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number, widthMil: number, heightMil: number, areaMil: number } | null}
670
+ */
671
+ static #unionBounds(boundsList) {
672
+ if (!boundsList.length) {
673
+ return null
674
+ }
675
+
676
+ const bounds = boundsList.reduce(
677
+ (nextBounds, meshBounds) => ({
678
+ minX: Math.min(nextBounds.minX, meshBounds.minX),
679
+ minY: Math.min(nextBounds.minY, meshBounds.minY),
680
+ minZ: Math.min(nextBounds.minZ, meshBounds.minZ),
681
+ maxX: Math.max(nextBounds.maxX, meshBounds.maxX),
682
+ maxY: Math.max(nextBounds.maxY, meshBounds.maxY),
683
+ maxZ: Math.max(nextBounds.maxZ, meshBounds.maxZ)
684
+ }),
685
+ {
686
+ minX: Number.POSITIVE_INFINITY,
687
+ minY: Number.POSITIVE_INFINITY,
688
+ minZ: Number.POSITIVE_INFINITY,
689
+ maxX: Number.NEGATIVE_INFINITY,
690
+ maxY: Number.NEGATIVE_INFINITY,
691
+ maxZ: Number.NEGATIVE_INFINITY
692
+ }
693
+ )
694
+ const widthMil = bounds.maxX - bounds.minX
695
+ const heightMil = bounds.maxY - bounds.minY
696
+
697
+ if (!(widthMil > 0) || !(heightMil > 0)) {
698
+ return null
699
+ }
700
+
701
+ return {
702
+ ...bounds,
703
+ widthMil,
704
+ heightMil,
705
+ areaMil: widthMil * heightMil
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Returns true when a mesh footprint is contained in the board envelope.
711
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} meshBounds Mesh bounds.
712
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number, widthMil: number, heightMil: number } | null} boardBounds Board dimensions.
713
+ * @returns {boolean}
714
+ */
715
+ static #isMostlyInsideBoard(meshBounds, boardBounds) {
716
+ if (!meshBounds || !boardBounds) {
717
+ return false
718
+ }
719
+
720
+ const toleranceX = boardBounds.widthMil * 0.05
721
+ const toleranceY = boardBounds.heightMil * 0.05
722
+
723
+ return (
724
+ meshBounds.minX >= boardBounds.minX - toleranceX &&
725
+ meshBounds.maxX <= boardBounds.maxX + toleranceX &&
726
+ meshBounds.minY >= boardBounds.minY - toleranceY &&
727
+ meshBounds.maxY <= boardBounds.maxY + toleranceY
728
+ )
729
+ }
730
+
731
+ /**
732
+ * Resolves a mesh's source geometry bounds in mil.
733
+ * @param {any} object Render object.
734
+ * @returns {{ minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number, sizeX: number, sizeY: number, sizeZ: number } | null}
735
+ */
736
+ static #resolveMeshBoundsMil(object) {
737
+ const positions = object?.geometry?.attributes?.position?.array
738
+ if (!positions?.length) {
739
+ return null
740
+ }
741
+
742
+ let minX = Number.POSITIVE_INFINITY
743
+ let minY = Number.POSITIVE_INFINITY
744
+ let minZ = Number.POSITIVE_INFINITY
745
+ let maxX = Number.NEGATIVE_INFINITY
746
+ let maxY = Number.NEGATIVE_INFINITY
747
+ let maxZ = Number.NEGATIVE_INFINITY
748
+
749
+ for (let index = 0; index < positions.length; index += 3) {
750
+ const x = Number(positions[index] || 0) * 1000
751
+ const y = Number(positions[index + 1] || 0) * 1000
752
+ const z = Number(positions[index + 2] || 0) * 1000
753
+
754
+ minX = Math.min(minX, x)
755
+ minY = Math.min(minY, y)
756
+ minZ = Math.min(minZ, z)
757
+ maxX = Math.max(maxX, x)
758
+ maxY = Math.max(maxY, y)
759
+ maxZ = Math.max(maxZ, z)
760
+ }
761
+
762
+ if (
763
+ !Number.isFinite(minX) ||
764
+ !Number.isFinite(minY) ||
765
+ !Number.isFinite(minZ) ||
766
+ !Number.isFinite(maxX) ||
767
+ !Number.isFinite(maxY) ||
768
+ !Number.isFinite(maxZ)
769
+ ) {
770
+ return null
771
+ }
772
+
773
+ return {
774
+ minX,
775
+ minY,
776
+ minZ,
777
+ maxX,
778
+ maxY,
779
+ maxZ,
780
+ sizeX: maxX - minX,
781
+ sizeY: maxY - minY,
782
+ sizeZ: maxZ - minZ
783
+ }
784
+ }
785
+
786
+ /**
787
+ * Resolves an approximate triangle count for one mesh.
788
+ * @param {any} object Render object.
789
+ * @returns {number}
790
+ */
791
+ static #resolveTriangleCount(object) {
792
+ const indexCount =
793
+ Number(object?.geometry?.index?.count) ||
794
+ Number(object?.geometry?.index?.array?.length) ||
795
+ 0
796
+
797
+ if (indexCount > 0) {
798
+ return Math.floor(indexCount / 3)
799
+ }
800
+
801
+ return Math.floor(
802
+ Number(object?.geometry?.attributes?.position?.count || 0) / 3
803
+ )
804
+ }
805
+
806
+ /**
807
+ * Resolves a material color into normalized RGB components.
808
+ * @param {any} color Material color.
809
+ * @returns {{ r: number, g: number, b: number } | null}
810
+ */
811
+ static #resolveMaterialRgb(color) {
812
+ if (
813
+ Number.isFinite(Number(color?.r)) &&
814
+ Number.isFinite(Number(color?.g)) &&
815
+ Number.isFinite(Number(color?.b))
816
+ ) {
817
+ return {
818
+ r: Number(color.r),
819
+ g: Number(color.g),
820
+ b: Number(color.b)
821
+ }
822
+ }
823
+
824
+ if (Number.isInteger(color)) {
825
+ return {
826
+ r: ((color >> 16) & 255) / 255,
827
+ g: ((color >> 8) & 255) / 255,
828
+ b: (color & 255) / 255
829
+ }
830
+ }
831
+
832
+ return null
833
+ }
834
+
835
+ /**
836
+ * Traverses a model object tree, including test doubles without `traverse`.
837
+ * @param {any} root Root object.
838
+ * @param {(object: any) => void} visitor Visitor callback.
839
+ * @returns {void}
840
+ */
841
+ static #traverseModelObjects(root, visitor) {
842
+ if (!root) {
843
+ return
844
+ }
845
+
846
+ if (typeof root.traverse === 'function') {
847
+ root.traverse(visitor)
848
+ return
849
+ }
850
+
851
+ visitor(root)
852
+ ;(Array.isArray(root.children) ? root.children : []).forEach((child) =>
853
+ PcbScene3dBoardAssemblyPresentation.#traverseModelObjects(
854
+ child,
855
+ visitor
856
+ )
857
+ )
858
+ }
859
+ }