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,912 @@
1
+ const MM_TO_MIL = 1000 / 25.4
2
+ const GLB_MAGIC = 0x46546c67
3
+ const GLB_VERSION = 2
4
+ const JSON_CHUNK_TYPE = 0x4e4f534a
5
+ const BIN_CHUNK_TYPE = 0x004e4942
6
+ const COMPONENT_BYTE_LENGTHS = new Map([
7
+ [5120, 1],
8
+ [5121, 1],
9
+ [5122, 2],
10
+ [5123, 2],
11
+ [5125, 4],
12
+ [5126, 4]
13
+ ])
14
+ const ACCESSOR_WIDTHS = new Map([
15
+ ['SCALAR', 1],
16
+ ['VEC2', 2],
17
+ ['VEC3', 3],
18
+ ['VEC4', 4],
19
+ ['MAT4', 16]
20
+ ])
21
+
22
+ /**
23
+ * Parses GLTF 2.0 JSON and binary GLB component models into assembly meshes.
24
+ */
25
+ export class PcbAssemblyGltfModelMeshParser {
26
+ /**
27
+ * Parses one GLTF JSON model.
28
+ * @param {{ name?: string, payloadText?: string, file?: any }} model Model metadata.
29
+ * @returns {Promise<object[]>}
30
+ */
31
+ static async parseGltfModel(model) {
32
+ const text = await PcbAssemblyGltfModelMeshParser.#readModelText(
33
+ model,
34
+ 'GLTF'
35
+ )
36
+ const gltf = JSON.parse(text)
37
+ return await PcbAssemblyGltfModelMeshParser.#parseDocument(
38
+ gltf,
39
+ model,
40
+ null
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Parses one binary GLB model.
46
+ * @param {{ name?: string, payloadBytes?: any, bytes?: any, file?: any }} model Model metadata.
47
+ * @returns {Promise<object[]>}
48
+ */
49
+ static async parseGlbModel(model) {
50
+ const bytes = await PcbAssemblyGltfModelMeshParser.#readModelBytes(
51
+ model,
52
+ 'GLB'
53
+ )
54
+ const parsed = PcbAssemblyGltfModelMeshParser.parseGlb(bytes)
55
+ return await PcbAssemblyGltfModelMeshParser.#parseDocument(
56
+ parsed.gltf,
57
+ model,
58
+ parsed.binaryBuffer
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Extracts a JSON document and optional binary chunk from GLB bytes.
64
+ * @param {Uint8Array} bytes GLB bytes.
65
+ * @returns {{ gltf: object, binaryBuffer: Uint8Array | null }}
66
+ */
67
+ static parseGlb(bytes) {
68
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength < 20) {
69
+ throw new Error('GLB content is not available.')
70
+ }
71
+
72
+ const view = new DataView(
73
+ bytes.buffer,
74
+ bytes.byteOffset,
75
+ bytes.byteLength
76
+ )
77
+ if (
78
+ view.getUint32(0, true) !== GLB_MAGIC ||
79
+ view.getUint32(4, true) !== GLB_VERSION
80
+ ) {
81
+ throw new Error('GLB header is not valid.')
82
+ }
83
+
84
+ const totalLength = view.getUint32(8, true)
85
+ if (totalLength > bytes.byteLength) {
86
+ throw new Error('GLB length exceeds available bytes.')
87
+ }
88
+
89
+ let offset = 12
90
+ let json = null
91
+ let binaryBuffer = null
92
+ while (offset + 8 <= totalLength) {
93
+ const chunkLength = view.getUint32(offset, true)
94
+ const chunkType = view.getUint32(offset + 4, true)
95
+ const chunkOffset = offset + 8
96
+ const chunkEnd = chunkOffset + chunkLength
97
+ if (chunkEnd > totalLength) {
98
+ throw new Error('GLB chunk exceeds container length.')
99
+ }
100
+
101
+ const chunk = bytes.slice(chunkOffset, chunkEnd)
102
+ if (chunkType === JSON_CHUNK_TYPE) {
103
+ json = JSON.parse(new TextDecoder().decode(chunk).trim())
104
+ } else if (chunkType === BIN_CHUNK_TYPE) {
105
+ binaryBuffer = chunk
106
+ }
107
+ offset = chunkEnd
108
+ }
109
+
110
+ if (!json) {
111
+ throw new Error('GLB JSON chunk is missing.')
112
+ }
113
+
114
+ return { gltf: json, binaryBuffer }
115
+ }
116
+
117
+ /**
118
+ * Parses a loaded GLTF document.
119
+ * @param {object} gltf GLTF JSON.
120
+ * @param {object} model Model metadata.
121
+ * @param {Uint8Array | null} binaryBuffer Optional GLB binary chunk.
122
+ * @returns {Promise<object[]>}
123
+ */
124
+ static async #parseDocument(gltf, model, binaryBuffer) {
125
+ const buffers = await PcbAssemblyGltfModelMeshParser.#resolveBuffers(
126
+ gltf,
127
+ model,
128
+ binaryBuffer
129
+ )
130
+ const meshes = []
131
+ const sceneNodes =
132
+ PcbAssemblyGltfModelMeshParser.#sceneNodeIndexes(gltf)
133
+
134
+ if (sceneNodes.length) {
135
+ sceneNodes.forEach((nodeIndex) => {
136
+ PcbAssemblyGltfModelMeshParser.#appendNodeMeshes(
137
+ gltf,
138
+ buffers,
139
+ nodeIndex,
140
+ PcbAssemblyGltfModelMeshParser.#identityMatrix(),
141
+ meshes
142
+ )
143
+ })
144
+ } else {
145
+ PcbAssemblyGltfModelMeshParser.#array(gltf?.meshes).forEach(
146
+ (mesh, index) => {
147
+ PcbAssemblyGltfModelMeshParser.#appendMeshPrimitives(
148
+ gltf,
149
+ buffers,
150
+ mesh,
151
+ PcbAssemblyGltfModelMeshParser.#identityMatrix(),
152
+ meshes,
153
+ index
154
+ )
155
+ }
156
+ )
157
+ }
158
+
159
+ if (!meshes.length) {
160
+ throw new Error('No mesh geometry was found in GLTF.')
161
+ }
162
+
163
+ return meshes.map((mesh, index) => ({
164
+ ...mesh,
165
+ name:
166
+ mesh.name || String(model?.name || 'gltf-model-' + (index + 1))
167
+ }))
168
+ }
169
+
170
+ /**
171
+ * Appends meshes from one node and its children.
172
+ * @param {object} gltf GLTF JSON.
173
+ * @param {Uint8Array[]} buffers Loaded buffers.
174
+ * @param {number} nodeIndex Node index.
175
+ * @param {number[]} parentMatrix Parent transform matrix.
176
+ * @param {object[]} meshes Mutable mesh output.
177
+ * @returns {void}
178
+ */
179
+ static #appendNodeMeshes(gltf, buffers, nodeIndex, parentMatrix, meshes) {
180
+ const node = PcbAssemblyGltfModelMeshParser.#array(gltf?.nodes)[
181
+ nodeIndex
182
+ ]
183
+ if (!node) {
184
+ return
185
+ }
186
+
187
+ const matrix = PcbAssemblyGltfModelMeshParser.#multiplyMatrices(
188
+ parentMatrix,
189
+ PcbAssemblyGltfModelMeshParser.#nodeMatrix(node)
190
+ )
191
+ const meshIndex = Number(node.mesh)
192
+ if (Number.isInteger(meshIndex)) {
193
+ PcbAssemblyGltfModelMeshParser.#appendMeshPrimitives(
194
+ gltf,
195
+ buffers,
196
+ PcbAssemblyGltfModelMeshParser.#array(gltf?.meshes)[meshIndex],
197
+ matrix,
198
+ meshes,
199
+ meshIndex,
200
+ node
201
+ )
202
+ }
203
+
204
+ PcbAssemblyGltfModelMeshParser.#array(node.children).forEach(
205
+ (childIndex) => {
206
+ PcbAssemblyGltfModelMeshParser.#appendNodeMeshes(
207
+ gltf,
208
+ buffers,
209
+ Number(childIndex),
210
+ matrix,
211
+ meshes
212
+ )
213
+ }
214
+ )
215
+ }
216
+
217
+ /**
218
+ * Appends primitives from one GLTF mesh.
219
+ * @param {object} gltf GLTF JSON.
220
+ * @param {Uint8Array[]} buffers Loaded buffers.
221
+ * @param {object | undefined} mesh GLTF mesh.
222
+ * @param {number[]} matrix Node transform matrix.
223
+ * @param {object[]} meshes Mutable mesh output.
224
+ * @param {number} meshIndex Mesh index.
225
+ * @param {object | null} [node] Node metadata.
226
+ * @returns {void}
227
+ */
228
+ static #appendMeshPrimitives(
229
+ gltf,
230
+ buffers,
231
+ mesh,
232
+ matrix,
233
+ meshes,
234
+ meshIndex,
235
+ node = null
236
+ ) {
237
+ PcbAssemblyGltfModelMeshParser.#array(mesh?.primitives).forEach(
238
+ (primitive, primitiveIndex) => {
239
+ const parsed = PcbAssemblyGltfModelMeshParser.#primitiveMesh(
240
+ gltf,
241
+ buffers,
242
+ mesh,
243
+ primitive,
244
+ matrix,
245
+ meshIndex,
246
+ primitiveIndex,
247
+ node
248
+ )
249
+ if (parsed) {
250
+ meshes.push(parsed)
251
+ }
252
+ }
253
+ )
254
+ }
255
+
256
+ /**
257
+ * Converts one GLTF primitive into an assembly mesh.
258
+ * @param {object} gltf GLTF JSON.
259
+ * @param {Uint8Array[]} buffers Loaded buffers.
260
+ * @param {object | undefined} mesh GLTF mesh.
261
+ * @param {object} primitive GLTF primitive.
262
+ * @param {number[]} matrix Node transform matrix.
263
+ * @param {number} meshIndex Mesh index.
264
+ * @param {number} primitiveIndex Primitive index.
265
+ * @param {object | null} node Node metadata.
266
+ * @returns {object | null}
267
+ */
268
+ static #primitiveMesh(
269
+ gltf,
270
+ buffers,
271
+ mesh,
272
+ primitive,
273
+ matrix,
274
+ meshIndex,
275
+ primitiveIndex,
276
+ node
277
+ ) {
278
+ const positionAccessor = primitive?.attributes?.POSITION
279
+ if (!Number.isInteger(positionAccessor)) {
280
+ return null
281
+ }
282
+
283
+ const positions = PcbAssemblyGltfModelMeshParser.#readAccessor(
284
+ gltf,
285
+ buffers,
286
+ positionAccessor
287
+ )
288
+ const vertices = positions.map((point) =>
289
+ PcbAssemblyGltfModelMeshParser.#pointMmToMil(
290
+ PcbAssemblyGltfModelMeshParser.#transformPoint(matrix, point)
291
+ )
292
+ )
293
+ const indexes = Number.isInteger(primitive?.indices)
294
+ ? PcbAssemblyGltfModelMeshParser.#readAccessor(
295
+ gltf,
296
+ buffers,
297
+ primitive.indices
298
+ ).flat()
299
+ : vertices.map((_vertex, index) => index)
300
+ const faces =
301
+ PcbAssemblyGltfModelMeshParser.#triangleFacesFromIndexes(indexes)
302
+
303
+ if (!vertices.length || !faces.length) {
304
+ return null
305
+ }
306
+
307
+ return {
308
+ name: PcbAssemblyGltfModelMeshParser.#primitiveName(
309
+ mesh,
310
+ node,
311
+ meshIndex,
312
+ primitiveIndex
313
+ ),
314
+ vertices,
315
+ faces,
316
+ ...PcbAssemblyGltfModelMeshParser.#primitiveMaterial(
317
+ gltf,
318
+ primitive
319
+ )
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Reads an accessor into numeric tuples.
325
+ * @param {object} gltf GLTF JSON.
326
+ * @param {Uint8Array[]} buffers Loaded buffers.
327
+ * @param {number} accessorIndex Accessor index.
328
+ * @returns {number[][]}
329
+ */
330
+ static #readAccessor(gltf, buffers, accessorIndex) {
331
+ const accessor = PcbAssemblyGltfModelMeshParser.#array(gltf?.accessors)[
332
+ accessorIndex
333
+ ]
334
+ const bufferView = PcbAssemblyGltfModelMeshParser.#array(
335
+ gltf?.bufferViews
336
+ )[Number(accessor?.bufferView)]
337
+ if (!accessor || !bufferView) {
338
+ return []
339
+ }
340
+
341
+ const componentLength =
342
+ COMPONENT_BYTE_LENGTHS.get(Number(accessor.componentType)) || 0
343
+ const width =
344
+ ACCESSOR_WIDTHS.get(String(accessor.type || 'SCALAR')) || 1
345
+ const buffer =
346
+ buffers[Number(bufferView.buffer || 0)] || new Uint8Array()
347
+ const view = new DataView(
348
+ buffer.buffer,
349
+ buffer.byteOffset,
350
+ buffer.byteLength
351
+ )
352
+ const stride =
353
+ Number(bufferView.byteStride || 0) || componentLength * width
354
+ const baseOffset =
355
+ Number(bufferView.byteOffset || 0) +
356
+ Number(accessor.byteOffset || 0)
357
+ const values = []
358
+
359
+ for (let index = 0; index < Number(accessor.count || 0); index += 1) {
360
+ const tuple = []
361
+ const tupleOffset = baseOffset + index * stride
362
+ for (let entry = 0; entry < width; entry += 1) {
363
+ tuple.push(
364
+ PcbAssemblyGltfModelMeshParser.#readComponent(
365
+ view,
366
+ tupleOffset + entry * componentLength,
367
+ Number(accessor.componentType),
368
+ accessor.normalized === true
369
+ )
370
+ )
371
+ }
372
+ values.push(tuple)
373
+ }
374
+
375
+ return values
376
+ }
377
+
378
+ /**
379
+ * Reads one accessor component.
380
+ * @param {DataView} view Binary view.
381
+ * @param {number} offset Byte offset.
382
+ * @param {number} componentType GLTF component type.
383
+ * @param {boolean} normalized Whether integer data is normalized.
384
+ * @returns {number}
385
+ */
386
+ static #readComponent(view, offset, componentType, normalized) {
387
+ if (offset < 0 || offset >= view.byteLength) {
388
+ return 0
389
+ }
390
+
391
+ if (componentType === 5120) {
392
+ const value = view.getInt8(offset)
393
+ return normalized ? Math.max(value / 127, -1) : value
394
+ }
395
+ if (componentType === 5121) {
396
+ const value = view.getUint8(offset)
397
+ return normalized ? value / 255 : value
398
+ }
399
+ if (componentType === 5122) {
400
+ const value = view.getInt16(offset, true)
401
+ return normalized ? Math.max(value / 32767, -1) : value
402
+ }
403
+ if (componentType === 5123) {
404
+ const value = view.getUint16(offset, true)
405
+ return normalized ? value / 65535 : value
406
+ }
407
+ if (componentType === 5125) {
408
+ return view.getUint32(offset, true)
409
+ }
410
+ if (componentType === 5126) {
411
+ return view.getFloat32(offset, true)
412
+ }
413
+ return 0
414
+ }
415
+
416
+ /**
417
+ * Builds triangle faces from flat indexes.
418
+ * @param {number[]} indexes Flat indexes.
419
+ * @returns {number[][]}
420
+ */
421
+ static #triangleFacesFromIndexes(indexes) {
422
+ const faces = []
423
+ for (let index = 0; index + 2 < indexes.length; index += 3) {
424
+ faces.push([
425
+ Number(indexes[index] || 0),
426
+ Number(indexes[index + 1] || 0),
427
+ Number(indexes[index + 2] || 0)
428
+ ])
429
+ }
430
+ return faces
431
+ }
432
+
433
+ /**
434
+ * Resolves material properties for one primitive.
435
+ * @param {object} gltf GLTF JSON.
436
+ * @param {object} primitive GLTF primitive.
437
+ * @returns {{ color?: number[] }}
438
+ */
439
+ static #primitiveMaterial(gltf, primitive) {
440
+ const materialIndex = Number(primitive?.material)
441
+ const material = Number.isInteger(materialIndex)
442
+ ? PcbAssemblyGltfModelMeshParser.#array(gltf?.materials)[
443
+ materialIndex
444
+ ]
445
+ : null
446
+ const color =
447
+ material?.pbrMetallicRoughness?.baseColorFactor ||
448
+ material?.baseColorFactor
449
+
450
+ if (!Array.isArray(color) || color.length < 3) {
451
+ return {}
452
+ }
453
+
454
+ const rgb = [0, 1, 2].map((index) =>
455
+ PcbAssemblyGltfModelMeshParser.#clampUnit(color[index])
456
+ )
457
+ const alpha = PcbAssemblyGltfModelMeshParser.#clampUnit(color[3], 1)
458
+
459
+ return {
460
+ color: alpha < 1 ? [...rgb, alpha] : rgb
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Clamps a numeric channel into the unit interval.
466
+ * @param {unknown} value Candidate number.
467
+ * @param {number} [fallback] Fallback value.
468
+ * @returns {number}
469
+ */
470
+ static #clampUnit(value, fallback = 0) {
471
+ const number = Number(value)
472
+ return Number.isFinite(number)
473
+ ? Math.min(Math.max(number, 0), 1)
474
+ : fallback
475
+ }
476
+
477
+ /**
478
+ * Resolves a stable primitive mesh name.
479
+ * @param {object | undefined} mesh GLTF mesh.
480
+ * @param {object | null} node GLTF node.
481
+ * @param {number} meshIndex Mesh index.
482
+ * @param {number} primitiveIndex Primitive index.
483
+ * @returns {string}
484
+ */
485
+ static #primitiveName(mesh, node, meshIndex, primitiveIndex) {
486
+ return String(
487
+ node?.name ||
488
+ mesh?.name ||
489
+ 'gltf-mesh-' + (meshIndex + 1) + '-' + (primitiveIndex + 1)
490
+ )
491
+ }
492
+
493
+ /**
494
+ * Resolves all declared buffers.
495
+ * @param {object} gltf GLTF JSON.
496
+ * @param {object} model Model metadata.
497
+ * @param {Uint8Array | null} binaryBuffer GLB binary chunk.
498
+ * @returns {Promise<Uint8Array[]>}
499
+ */
500
+ static async #resolveBuffers(gltf, model, binaryBuffer) {
501
+ const buffers = []
502
+ for (const buffer of PcbAssemblyGltfModelMeshParser.#array(
503
+ gltf?.buffers
504
+ )) {
505
+ buffers.push(
506
+ await PcbAssemblyGltfModelMeshParser.#resolveBuffer(
507
+ buffer,
508
+ model,
509
+ binaryBuffer
510
+ )
511
+ )
512
+ }
513
+ return buffers
514
+ }
515
+
516
+ /**
517
+ * Resolves one GLTF buffer.
518
+ * @param {object} buffer GLTF buffer.
519
+ * @param {object} model Model metadata.
520
+ * @param {Uint8Array | null} binaryBuffer GLB binary chunk.
521
+ * @returns {Promise<Uint8Array>}
522
+ */
523
+ static async #resolveBuffer(buffer, model, binaryBuffer) {
524
+ const uri = String(buffer?.uri || '')
525
+ if (!uri && binaryBuffer) {
526
+ return binaryBuffer.slice(0, Number(buffer?.byteLength || 0))
527
+ }
528
+
529
+ if (uri.startsWith('data:')) {
530
+ return PcbAssemblyGltfModelMeshParser.#dataUriBytes(uri)
531
+ }
532
+
533
+ if (uri) {
534
+ const external = await PcbAssemblyGltfModelMeshParser.#readResource(
535
+ uri,
536
+ model
537
+ )
538
+ if (external) {
539
+ return external
540
+ }
541
+ }
542
+
543
+ throw new Error('GLTF buffer content is not available.')
544
+ }
545
+
546
+ /**
547
+ * Reads a resource that matches an external buffer URI.
548
+ * @param {string} uri Buffer URI.
549
+ * @param {object} model Model metadata.
550
+ * @returns {Promise<Uint8Array | null>}
551
+ */
552
+ static async #readResource(uri, model) {
553
+ const normalizedUri = PcbAssemblyGltfModelMeshParser.#normalizePath(uri)
554
+ const uriName = normalizedUri.split('/').pop()
555
+
556
+ for (const resource of PcbAssemblyGltfModelMeshParser.#resources(
557
+ model
558
+ )) {
559
+ const names =
560
+ PcbAssemblyGltfModelMeshParser.#resourceNames(resource)
561
+ if (
562
+ names.some(
563
+ (name) =>
564
+ name === normalizedUri ||
565
+ name.endsWith('/' + normalizedUri) ||
566
+ name.split('/').pop() === uriName
567
+ )
568
+ ) {
569
+ const bytes =
570
+ await PcbAssemblyGltfModelMeshParser.#readAnyBytes(resource)
571
+ if (bytes) {
572
+ return bytes
573
+ }
574
+ }
575
+ }
576
+
577
+ return null
578
+ }
579
+
580
+ /**
581
+ * Collects sidecar resource candidates from model metadata.
582
+ * @param {object} model Model metadata.
583
+ * @returns {object[]}
584
+ */
585
+ static #resources(model) {
586
+ const resources = []
587
+ ;[
588
+ model?.resources,
589
+ model?.relatedFiles,
590
+ model?.assets,
591
+ model?.files,
592
+ model?.externalBuffers,
593
+ model?.bufferFiles
594
+ ].forEach((value) => {
595
+ if (Array.isArray(value)) {
596
+ resources.push(...value)
597
+ } else if (value && typeof value === 'object') {
598
+ Object.entries(value).forEach(([name, entry]) => {
599
+ resources.push({ name, ...(entry || {}) })
600
+ })
601
+ }
602
+ })
603
+ return resources
604
+ }
605
+
606
+ /**
607
+ * Returns normalized resource names used for URI matching.
608
+ * @param {object} resource Resource metadata.
609
+ * @returns {string[]}
610
+ */
611
+ static #resourceNames(resource) {
612
+ return [
613
+ resource?.uri,
614
+ resource?.relativePath,
615
+ resource?.name,
616
+ resource?.file?.name,
617
+ resource?.sourceUrl
618
+ ]
619
+ .map((value) =>
620
+ PcbAssemblyGltfModelMeshParser.#normalizePath(value)
621
+ )
622
+ .filter(Boolean)
623
+ }
624
+
625
+ /**
626
+ * Builds node indexes from the active scene.
627
+ * @param {object} gltf GLTF JSON.
628
+ * @returns {number[]}
629
+ */
630
+ static #sceneNodeIndexes(gltf) {
631
+ const scenes = PcbAssemblyGltfModelMeshParser.#array(gltf?.scenes)
632
+ const sceneIndex = Number.isInteger(gltf?.scene) ? gltf.scene : 0
633
+ const scene = scenes[sceneIndex] || scenes[0]
634
+ if (Array.isArray(scene?.nodes)) {
635
+ return scene.nodes
636
+ .map((index) => Number(index))
637
+ .filter(Number.isInteger)
638
+ }
639
+
640
+ return PcbAssemblyGltfModelMeshParser.#array(gltf?.nodes)
641
+ .map((node, index) => (Number.isInteger(node?.mesh) ? index : -1))
642
+ .filter((index) => index >= 0)
643
+ }
644
+
645
+ /**
646
+ * Builds a node transform matrix.
647
+ * @param {object} node GLTF node.
648
+ * @returns {number[]}
649
+ */
650
+ static #nodeMatrix(node) {
651
+ if (Array.isArray(node?.matrix) && node.matrix.length >= 16) {
652
+ return node.matrix.slice(0, 16).map((value) => Number(value || 0))
653
+ }
654
+
655
+ return PcbAssemblyGltfModelMeshParser.#trsMatrix(
656
+ node?.translation,
657
+ node?.rotation,
658
+ node?.scale
659
+ )
660
+ }
661
+
662
+ /**
663
+ * Builds a transform matrix from translation, rotation, and scale.
664
+ * @param {number[] | undefined} translation Node translation.
665
+ * @param {number[] | undefined} rotation Node quaternion.
666
+ * @param {number[] | undefined} scale Node scale.
667
+ * @returns {number[]}
668
+ */
669
+ static #trsMatrix(translation, rotation, scale) {
670
+ const t = [0, 1, 2].map((index) => Number(translation?.[index] || 0))
671
+ const s = [0, 1, 2].map((index) => Number(scale?.[index] ?? 1) || 1)
672
+ const q = [
673
+ Number(rotation?.[0] || 0),
674
+ Number(rotation?.[1] || 0),
675
+ Number(rotation?.[2] || 0),
676
+ Number(rotation?.[3] ?? 1) || 1
677
+ ]
678
+ const x2 = q[0] + q[0]
679
+ const y2 = q[1] + q[1]
680
+ const z2 = q[2] + q[2]
681
+ const xx = q[0] * x2
682
+ const xy = q[0] * y2
683
+ const xz = q[0] * z2
684
+ const yy = q[1] * y2
685
+ const yz = q[1] * z2
686
+ const zz = q[2] * z2
687
+ const wx = q[3] * x2
688
+ const wy = q[3] * y2
689
+ const wz = q[3] * z2
690
+
691
+ return [
692
+ (1 - (yy + zz)) * s[0],
693
+ (xy + wz) * s[0],
694
+ (xz - wy) * s[0],
695
+ 0,
696
+ (xy - wz) * s[1],
697
+ (1 - (xx + zz)) * s[1],
698
+ (yz + wx) * s[1],
699
+ 0,
700
+ (xz + wy) * s[2],
701
+ (yz - wx) * s[2],
702
+ (1 - (xx + yy)) * s[2],
703
+ 0,
704
+ t[0],
705
+ t[1],
706
+ t[2],
707
+ 1
708
+ ]
709
+ }
710
+
711
+ /**
712
+ * Returns an identity matrix.
713
+ * @returns {number[]}
714
+ */
715
+ static #identityMatrix() {
716
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
717
+ }
718
+
719
+ /**
720
+ * Multiplies two column-major matrices.
721
+ * @param {number[]} left Left matrix.
722
+ * @param {number[]} right Right matrix.
723
+ * @returns {number[]}
724
+ */
725
+ static #multiplyMatrices(left, right) {
726
+ const result = new Array(16).fill(0)
727
+ for (let column = 0; column < 4; column += 1) {
728
+ for (let row = 0; row < 4; row += 1) {
729
+ for (let index = 0; index < 4; index += 1) {
730
+ result[column * 4 + row] +=
731
+ left[index * 4 + row] * right[column * 4 + index]
732
+ }
733
+ }
734
+ }
735
+ return result
736
+ }
737
+
738
+ /**
739
+ * Transforms one point by a column-major matrix.
740
+ * @param {number[]} matrix Transform matrix.
741
+ * @param {number[]} point Source point.
742
+ * @returns {number[]}
743
+ */
744
+ static #transformPoint(matrix, point) {
745
+ const x = Number(point?.[0] || 0)
746
+ const y = Number(point?.[1] || 0)
747
+ const z = Number(point?.[2] || 0)
748
+ return [
749
+ matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12],
750
+ matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13],
751
+ matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]
752
+ ]
753
+ }
754
+
755
+ /**
756
+ * Converts one point from millimeters into internal mils.
757
+ * @param {number[]} point Point in millimeters.
758
+ * @returns {number[]}
759
+ */
760
+ static #pointMmToMil(point) {
761
+ return [0, 1, 2].map((index) => Number(point?.[index] || 0) * MM_TO_MIL)
762
+ }
763
+
764
+ /**
765
+ * Decodes a data URI into bytes.
766
+ * @param {string} uri Data URI.
767
+ * @returns {Uint8Array}
768
+ */
769
+ static #dataUriBytes(uri) {
770
+ const commaIndex = uri.indexOf(',')
771
+ if (commaIndex < 0) {
772
+ return new Uint8Array()
773
+ }
774
+
775
+ const metadata = uri.slice(0, commaIndex)
776
+ const payload = uri.slice(commaIndex + 1)
777
+ if (metadata.includes(';base64')) {
778
+ return PcbAssemblyGltfModelMeshParser.#base64Bytes(payload)
779
+ }
780
+
781
+ return new TextEncoder().encode(decodeURIComponent(payload))
782
+ }
783
+
784
+ /**
785
+ * Decodes base64 in Node and browser runtimes.
786
+ * @param {string} value Base64 value.
787
+ * @returns {Uint8Array}
788
+ */
789
+ static #base64Bytes(value) {
790
+ if (typeof Buffer !== 'undefined') {
791
+ return new Uint8Array(Buffer.from(value, 'base64'))
792
+ }
793
+
794
+ const binary = atob(value)
795
+ const bytes = new Uint8Array(binary.length)
796
+ for (let index = 0; index < binary.length; index += 1) {
797
+ bytes[index] = binary.charCodeAt(index)
798
+ }
799
+ return bytes
800
+ }
801
+
802
+ /**
803
+ * Reads model metadata as UTF-8 text.
804
+ * @param {{ payloadText?: string, file?: any }} model Model metadata.
805
+ * @param {string} label Format label.
806
+ * @returns {Promise<string>}
807
+ */
808
+ static async #readModelText(model, label) {
809
+ if (typeof model?.payloadText === 'string') {
810
+ return model.payloadText
811
+ }
812
+ if (typeof model?.file?.text === 'function') {
813
+ return await model.file.text()
814
+ }
815
+
816
+ return new TextDecoder().decode(
817
+ await PcbAssemblyGltfModelMeshParser.#readModelBytes(model, label)
818
+ )
819
+ }
820
+
821
+ /**
822
+ * Reads model metadata as bytes.
823
+ * @param {{ payloadText?: string, payloadBytes?: any, bytes?: any, file?: any }} model Model metadata.
824
+ * @param {string} label Format label.
825
+ * @returns {Promise<Uint8Array>}
826
+ */
827
+ static async #readModelBytes(model, label) {
828
+ const bytes = await PcbAssemblyGltfModelMeshParser.#readAnyBytes(model)
829
+ if (bytes) {
830
+ return bytes
831
+ }
832
+
833
+ throw new Error(label + ' model content is not available.')
834
+ }
835
+
836
+ /**
837
+ * Reads byte-like values from common metadata shapes.
838
+ * @param {{ payloadText?: string, payloadBytes?: any, bytes?: any, data?: any, file?: any }} source Source metadata.
839
+ * @returns {Promise<Uint8Array | null>}
840
+ */
841
+ static async #readAnyBytes(source) {
842
+ if (typeof source?.payloadText === 'string') {
843
+ return new TextEncoder().encode(source.payloadText)
844
+ }
845
+
846
+ for (const value of [
847
+ source?.payloadBytes,
848
+ source?.bytes,
849
+ source?.data,
850
+ source?.file,
851
+ source
852
+ ]) {
853
+ const bytes =
854
+ await PcbAssemblyGltfModelMeshParser.#bytesFromValue(value)
855
+ if (bytes) {
856
+ return bytes
857
+ }
858
+ }
859
+
860
+ return null
861
+ }
862
+
863
+ /**
864
+ * Converts one byte-like value to a Uint8Array.
865
+ * @param {any} value Candidate value.
866
+ * @returns {Promise<Uint8Array | null>}
867
+ */
868
+ static async #bytesFromValue(value) {
869
+ if (!value || typeof value === 'string') {
870
+ return null
871
+ }
872
+ if (value instanceof Uint8Array) {
873
+ return value
874
+ }
875
+ if (value instanceof ArrayBuffer) {
876
+ return new Uint8Array(value)
877
+ }
878
+ if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
879
+ return new Uint8Array(
880
+ value.buffer,
881
+ value.byteOffset,
882
+ value.byteLength
883
+ )
884
+ }
885
+ if (typeof value.arrayBuffer === 'function') {
886
+ return new Uint8Array(await value.arrayBuffer())
887
+ }
888
+ return null
889
+ }
890
+
891
+ /**
892
+ * Normalizes a path-like value for matching.
893
+ * @param {unknown} value Path candidate.
894
+ * @returns {string}
895
+ */
896
+ static #normalizePath(value) {
897
+ return String(value || '')
898
+ .split(/[?#]/u)[0]
899
+ .replace(/\\/gu, '/')
900
+ .replace(/^\/+/u, '')
901
+ .toLowerCase()
902
+ }
903
+
904
+ /**
905
+ * Normalizes a value to an array.
906
+ * @param {unknown} value Candidate value.
907
+ * @returns {any[]}
908
+ */
909
+ static #array(value) {
910
+ return Array.isArray(value) ? value : []
911
+ }
912
+ }