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,801 @@
1
+ import { PcbAssemblyExportCoordinateFrame } from './PcbAssemblyExportCoordinateFrame.mjs'
2
+ import { PcbAssemblyPolygonTriangulator } from './PcbAssemblyPolygonTriangulator.mjs'
3
+
4
+ const FLOAT_COMPONENT = 5126
5
+ const UNSIGNED_INT_COMPONENT = 5125
6
+ const ARRAY_BUFFER_TARGET = 34962
7
+ const ELEMENT_ARRAY_BUFFER_TARGET = 34963
8
+ const TRIANGLES_MODE = 4
9
+ const GLB_MAGIC = 0x46546c67
10
+ const GLB_VERSION = 2
11
+ const JSON_CHUNK_TYPE = 0x4e4f534a
12
+ const BIN_CHUNK_TYPE = 0x004e4942
13
+
14
+ /**
15
+ * Writes faceted PCB assembly meshes as GLTF 2.0 JSON or binary GLB.
16
+ */
17
+ export class PcbAssemblyGltfWriter {
18
+ /**
19
+ * Writes a GLTF or GLB assembly document.
20
+ * @param {{ name?: string, meshes?: object[], format?: string, binary?: boolean }} assembly Assembly data.
21
+ * @returns {object | Uint8Array}
22
+ */
23
+ static write(assembly = {}) {
24
+ const format = String(assembly?.format || '').toLowerCase()
25
+ const binary = assembly?.binary === true || format === 'glb'
26
+ const document = PcbAssemblyGltfWriter.#buildDocument(assembly, binary)
27
+
28
+ return binary
29
+ ? PcbAssemblyGltfWriter.#writeGlb(document.gltf, document.buffer)
30
+ : document.gltf
31
+ }
32
+
33
+ /**
34
+ * Builds the GLTF JSON tree and shared binary buffer.
35
+ * @param {{ name?: string, meshes?: object[] }} assembly Assembly data.
36
+ * @param {boolean} binary Whether the document will be embedded in GLB.
37
+ * @returns {{ gltf: object, buffer: Uint8Array }}
38
+ */
39
+ static #buildDocument(assembly, binary) {
40
+ const state = PcbAssemblyGltfWriter.#createState()
41
+ const sourceMeshes = Array.isArray(assembly?.meshes)
42
+ ? assembly.meshes
43
+ : []
44
+
45
+ sourceMeshes.forEach((mesh) => {
46
+ PcbAssemblyGltfWriter.#appendMesh(state, mesh)
47
+ })
48
+
49
+ const buffer = PcbAssemblyGltfWriter.#concatBuffer(state.bufferParts)
50
+ const gltf = {
51
+ asset: {
52
+ version: '2.0',
53
+ generator: 'pcb-scene3d-viewer'
54
+ },
55
+ scene: 0,
56
+ scenes: [
57
+ {
58
+ name: PcbAssemblyGltfWriter.#safeName(
59
+ assembly?.name || 'pcb-assembly'
60
+ ),
61
+ nodes: state.nodes.map((_node, index) => index)
62
+ }
63
+ ],
64
+ nodes: state.nodes,
65
+ meshes: state.meshes,
66
+ materials: state.materials,
67
+ buffers: [
68
+ {
69
+ byteLength: buffer.byteLength
70
+ }
71
+ ],
72
+ bufferViews: state.bufferViews,
73
+ accessors: state.accessors
74
+ }
75
+
76
+ if (!binary) {
77
+ gltf.buffers[0].uri =
78
+ 'data:application/octet-stream;base64,' +
79
+ PcbAssemblyGltfWriter.#base64(buffer)
80
+ }
81
+ if (state.images.length) {
82
+ gltf.samplers = [
83
+ {
84
+ magFilter: 9729,
85
+ minFilter: 9729,
86
+ wrapS: 10497,
87
+ wrapT: 10497
88
+ }
89
+ ]
90
+ gltf.images = state.images
91
+ gltf.textures = state.textures
92
+ }
93
+
94
+ return { gltf, buffer }
95
+ }
96
+
97
+ /**
98
+ * Creates a mutable writer state object.
99
+ * @returns {object}
100
+ */
101
+ static #createState() {
102
+ return {
103
+ bufferParts: [],
104
+ byteLength: 0,
105
+ bufferViews: [],
106
+ accessors: [],
107
+ materials: [],
108
+ materialIndexes: new Map(),
109
+ images: [],
110
+ imageIndexes: new Map(),
111
+ textures: [],
112
+ textureIndexes: new Map(),
113
+ meshIndexes: new Map(),
114
+ meshes: [],
115
+ nodes: []
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Appends one mesh node to the document state.
121
+ * @param {object} state Writer state.
122
+ * @param {object} mesh Source mesh.
123
+ * @returns {void}
124
+ */
125
+ static #appendMesh(state, mesh) {
126
+ const name = PcbAssemblyGltfWriter.#safeName(mesh?.name || 'mesh')
127
+ const meshKey = PcbAssemblyGltfWriter.#meshReuseKey(mesh)
128
+ if (meshKey && state.meshIndexes.has(meshKey)) {
129
+ state.nodes.push({
130
+ name,
131
+ mesh: state.meshIndexes.get(meshKey)
132
+ })
133
+ return
134
+ }
135
+
136
+ const primitives = PcbAssemblyGltfWriter.#buildPrimitives(state, mesh)
137
+ if (!primitives.length) {
138
+ return
139
+ }
140
+
141
+ const meshIndex = state.meshes.length
142
+ state.meshes.push({
143
+ name,
144
+ primitives
145
+ })
146
+ if (meshKey) {
147
+ state.meshIndexes.set(meshKey, meshIndex)
148
+ }
149
+ state.nodes.push({
150
+ name,
151
+ mesh: meshIndex
152
+ })
153
+ }
154
+
155
+ /**
156
+ * Builds a stable key for meshes whose emitted geometry can be reused.
157
+ * @param {object} mesh Source mesh.
158
+ * @returns {string}
159
+ */
160
+ static #meshReuseKey(mesh) {
161
+ const vertices = Array.isArray(mesh?.vertices) ? mesh.vertices : []
162
+ const faces = Array.isArray(mesh?.faces) ? mesh.faces : []
163
+ if (!vertices.length || !faces.length) {
164
+ return ''
165
+ }
166
+
167
+ return JSON.stringify({
168
+ vertices: vertices.map((vertex) =>
169
+ [0, 1, 2].map((index) =>
170
+ PcbAssemblyGltfWriter.#roundedKeyNumber(vertex?.[index])
171
+ )
172
+ ),
173
+ faces: faces.map((face) =>
174
+ (Array.isArray(face) ? face : []).map((index) =>
175
+ Number(index || 0)
176
+ )
177
+ ),
178
+ color: PcbAssemblyGltfWriter.#color(mesh?.color, mesh?.opacity).map(
179
+ (value) => PcbAssemblyGltfWriter.#roundedKeyNumber(value)
180
+ ),
181
+ texture: {
182
+ top: String(mesh?.texture?.top || ''),
183
+ bottom: String(mesh?.texture?.bottom || '')
184
+ }
185
+ })
186
+ }
187
+
188
+ /**
189
+ * Builds GLTF primitives for one source mesh.
190
+ * @param {object} state Writer state.
191
+ * @param {object} mesh Source mesh.
192
+ * @returns {object[]}
193
+ */
194
+ static #buildPrimitives(state, mesh) {
195
+ const vertices = Array.isArray(mesh?.vertices) ? mesh.vertices : []
196
+ const faces = Array.isArray(mesh?.faces) ? mesh.faces : []
197
+ const bounds = PcbAssemblyGltfWriter.#meshBounds2d(vertices)
198
+ const groups = new Map()
199
+
200
+ faces.forEach((face) => {
201
+ const indexes = Array.isArray(face) ? face : []
202
+ const triangles = PcbAssemblyPolygonTriangulator.triangulateFace(
203
+ indexes,
204
+ vertices
205
+ )
206
+ const textureKind = PcbAssemblyGltfWriter.#faceTextureKind(
207
+ mesh,
208
+ indexes
209
+ )
210
+ const materialIndex = PcbAssemblyGltfWriter.#materialIndex(
211
+ state,
212
+ mesh,
213
+ textureKind
214
+ )
215
+ const groupKey = String(materialIndex) + ':' + textureKind
216
+ const group =
217
+ groups.get(groupKey) ||
218
+ PcbAssemblyGltfWriter.#createPrimitiveGroup(materialIndex)
219
+ groups.set(groupKey, group)
220
+
221
+ triangles.forEach((triangle) => {
222
+ PcbAssemblyGltfWriter.#appendTriangle(
223
+ group,
224
+ vertices,
225
+ triangle,
226
+ bounds,
227
+ textureKind !== 'solid'
228
+ )
229
+ })
230
+ })
231
+
232
+ return Array.from(groups.values())
233
+ .filter((group) => group.positions.length)
234
+ .map((group) => PcbAssemblyGltfWriter.#writePrimitive(state, group))
235
+ }
236
+
237
+ /**
238
+ * Creates a primitive accumulator.
239
+ * @param {number} materialIndex Material index.
240
+ * @returns {object}
241
+ */
242
+ static #createPrimitiveGroup(materialIndex) {
243
+ return {
244
+ materialIndex,
245
+ positions: [],
246
+ normals: [],
247
+ texcoords: [],
248
+ indexes: []
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Appends one flat-shaded triangle.
254
+ * @param {object} group Primitive group.
255
+ * @param {number[][]} vertices Source vertices.
256
+ * @param {number[]} triangle Source vertex indexes.
257
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds XY bounds.
258
+ * @param {boolean} includeTexcoords Whether to emit texture coordinates.
259
+ * @returns {void}
260
+ */
261
+ static #appendTriangle(
262
+ group,
263
+ vertices,
264
+ triangle,
265
+ bounds,
266
+ includeTexcoords
267
+ ) {
268
+ const points = triangle.map((index) =>
269
+ PcbAssemblyGltfWriter.#exportedVertex(vertices[index])
270
+ )
271
+ const normal = PcbAssemblyGltfWriter.#triangleNormal(points)
272
+ const firstIndex = group.positions.length / 3
273
+
274
+ points.forEach((point, index) => {
275
+ group.positions.push(point[0], point[1], point[2])
276
+ group.normals.push(normal[0], normal[1], normal[2])
277
+ if (includeTexcoords) {
278
+ const uv = PcbAssemblyGltfWriter.#uvForVertex(
279
+ vertices[triangle[index]],
280
+ bounds
281
+ )
282
+ group.texcoords.push(uv[0], uv[1])
283
+ }
284
+ })
285
+ group.indexes.push(firstIndex, firstIndex + 1, firstIndex + 2)
286
+ }
287
+
288
+ /**
289
+ * Serializes one primitive group into GLTF accessors.
290
+ * @param {object} state Writer state.
291
+ * @param {object} group Primitive group.
292
+ * @returns {object}
293
+ */
294
+ static #writePrimitive(state, group) {
295
+ const positionAccessor = PcbAssemblyGltfWriter.#appendAccessor(
296
+ state,
297
+ PcbAssemblyGltfWriter.#floatBytes(group.positions),
298
+ FLOAT_COMPONENT,
299
+ 'VEC3',
300
+ group.positions.length / 3,
301
+ ARRAY_BUFFER_TARGET,
302
+ PcbAssemblyGltfWriter.#accessorBounds(group.positions, 3)
303
+ )
304
+ const normalAccessor = PcbAssemblyGltfWriter.#appendAccessor(
305
+ state,
306
+ PcbAssemblyGltfWriter.#floatBytes(group.normals),
307
+ FLOAT_COMPONENT,
308
+ 'VEC3',
309
+ group.normals.length / 3,
310
+ ARRAY_BUFFER_TARGET
311
+ )
312
+ const indexAccessor = PcbAssemblyGltfWriter.#appendAccessor(
313
+ state,
314
+ PcbAssemblyGltfWriter.#uintBytes(group.indexes),
315
+ UNSIGNED_INT_COMPONENT,
316
+ 'SCALAR',
317
+ group.indexes.length,
318
+ ELEMENT_ARRAY_BUFFER_TARGET
319
+ )
320
+ const attributes = {
321
+ POSITION: positionAccessor,
322
+ NORMAL: normalAccessor
323
+ }
324
+
325
+ if (group.texcoords.length) {
326
+ attributes.TEXCOORD_0 = PcbAssemblyGltfWriter.#appendAccessor(
327
+ state,
328
+ PcbAssemblyGltfWriter.#floatBytes(group.texcoords),
329
+ FLOAT_COMPONENT,
330
+ 'VEC2',
331
+ group.texcoords.length / 2,
332
+ ARRAY_BUFFER_TARGET
333
+ )
334
+ }
335
+
336
+ return {
337
+ mode: TRIANGLES_MODE,
338
+ attributes,
339
+ indices: indexAccessor,
340
+ material: group.materialIndex
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Adds binary data and a matching accessor.
346
+ * @param {object} state Writer state.
347
+ * @param {Uint8Array} bytes Binary data.
348
+ * @param {number} componentType GLTF component type.
349
+ * @param {string} type GLTF accessor type.
350
+ * @param {number} count Element count.
351
+ * @param {number} target Buffer view target.
352
+ * @param {{ min: number[], max: number[] } | null} [bounds] Optional bounds.
353
+ * @returns {number}
354
+ */
355
+ static #appendAccessor(
356
+ state,
357
+ bytes,
358
+ componentType,
359
+ type,
360
+ count,
361
+ target,
362
+ bounds = null
363
+ ) {
364
+ const bufferView = PcbAssemblyGltfWriter.#appendBufferView(
365
+ state,
366
+ bytes,
367
+ target
368
+ )
369
+ const accessor = {
370
+ bufferView,
371
+ byteOffset: 0,
372
+ componentType,
373
+ count,
374
+ type
375
+ }
376
+
377
+ if (bounds) {
378
+ accessor.min = bounds.min
379
+ accessor.max = bounds.max
380
+ }
381
+
382
+ state.accessors.push(accessor)
383
+ return state.accessors.length - 1
384
+ }
385
+
386
+ /**
387
+ * Adds one aligned buffer view to the document.
388
+ * @param {object} state Writer state.
389
+ * @param {Uint8Array} bytes Binary data.
390
+ * @param {number} target Buffer target.
391
+ * @returns {number}
392
+ */
393
+ static #appendBufferView(state, bytes, target) {
394
+ const padding = PcbAssemblyGltfWriter.#paddingLength(state.byteLength)
395
+ if (padding) {
396
+ state.bufferParts.push(new Uint8Array(padding))
397
+ state.byteLength += padding
398
+ }
399
+
400
+ const bufferView = {
401
+ buffer: 0,
402
+ byteOffset: state.byteLength,
403
+ byteLength: bytes.byteLength,
404
+ target
405
+ }
406
+ state.bufferViews.push(bufferView)
407
+ state.bufferParts.push(bytes)
408
+ state.byteLength += bytes.byteLength
409
+ return state.bufferViews.length - 1
410
+ }
411
+
412
+ /**
413
+ * Resolves a material index for a face group.
414
+ * @param {object} state Writer state.
415
+ * @param {object} mesh Source mesh.
416
+ * @param {string} textureKind Texture kind.
417
+ * @returns {number}
418
+ */
419
+ static #materialIndex(state, mesh, textureKind) {
420
+ const color = PcbAssemblyGltfWriter.#color(mesh?.color, mesh?.opacity)
421
+ const textureUri =
422
+ textureKind === 'top'
423
+ ? mesh?.texture?.top
424
+ : textureKind === 'bottom'
425
+ ? mesh?.texture?.bottom
426
+ : ''
427
+ const key =
428
+ color.join(',') +
429
+ ':' +
430
+ String(textureKind || 'solid') +
431
+ ':' +
432
+ String(textureUri || '')
433
+
434
+ if (state.materialIndexes.has(key)) {
435
+ return state.materialIndexes.get(key)
436
+ }
437
+
438
+ const pbr = {
439
+ baseColorFactor: color,
440
+ metallicFactor: 0,
441
+ roughnessFactor: 0.72
442
+ }
443
+ if (textureUri) {
444
+ pbr.baseColorTexture = {
445
+ index: PcbAssemblyGltfWriter.#textureIndex(state, textureUri)
446
+ }
447
+ }
448
+
449
+ const material = {
450
+ name: textureKind === 'solid' ? 'solid' : textureKind + '-texture',
451
+ doubleSided: true,
452
+ pbrMetallicRoughness: pbr
453
+ }
454
+ if (color[3] < 1) {
455
+ material.alphaMode = 'BLEND'
456
+ }
457
+ state.materials.push(material)
458
+ state.materialIndexes.set(key, state.materials.length - 1)
459
+ return state.materials.length - 1
460
+ }
461
+
462
+ /**
463
+ * Resolves a texture index for a data URI.
464
+ * @param {object} state Writer state.
465
+ * @param {string} uri Image data URI.
466
+ * @returns {number}
467
+ */
468
+ static #textureIndex(state, uri) {
469
+ if (state.textureIndexes.has(uri)) {
470
+ return state.textureIndexes.get(uri)
471
+ }
472
+
473
+ const imageIndex = PcbAssemblyGltfWriter.#imageIndex(state, uri)
474
+ state.textures.push({
475
+ sampler: 0,
476
+ source: imageIndex
477
+ })
478
+ state.textureIndexes.set(uri, state.textures.length - 1)
479
+ return state.textures.length - 1
480
+ }
481
+
482
+ /**
483
+ * Resolves an image index for a data URI.
484
+ * @param {object} state Writer state.
485
+ * @param {string} uri Image data URI.
486
+ * @returns {number}
487
+ */
488
+ static #imageIndex(state, uri) {
489
+ if (state.imageIndexes.has(uri)) {
490
+ return state.imageIndexes.get(uri)
491
+ }
492
+
493
+ state.images.push({ uri })
494
+ state.imageIndexes.set(uri, state.images.length - 1)
495
+ return state.images.length - 1
496
+ }
497
+
498
+ /**
499
+ * Resolves which texture, if any, applies to a face.
500
+ * @param {object} mesh Source mesh.
501
+ * @param {number[]} face Face vertex indexes.
502
+ * @returns {'top' | 'bottom' | 'solid'}
503
+ */
504
+ static #faceTextureKind(mesh, face) {
505
+ if (!mesh?.texture?.top && !mesh?.texture?.bottom) {
506
+ return 'solid'
507
+ }
508
+
509
+ const normal = PcbAssemblyGltfWriter.#sourceFaceNormal(
510
+ mesh?.vertices || [],
511
+ face
512
+ )
513
+ if (normal[2] > 0.5 && mesh?.texture?.top) {
514
+ return 'top'
515
+ }
516
+ if (normal[2] < -0.5 && mesh?.texture?.bottom) {
517
+ return 'bottom'
518
+ }
519
+ return 'solid'
520
+ }
521
+
522
+ /**
523
+ * Computes a source-space face normal.
524
+ * @param {number[][]} vertices Source vertices.
525
+ * @param {number[]} face Face vertex indexes.
526
+ * @returns {number[]}
527
+ */
528
+ static #sourceFaceNormal(vertices, face) {
529
+ const points = face.map((index) => vertices[index]).filter(Boolean)
530
+ if (points.length < 3) {
531
+ return [0, 0, 1]
532
+ }
533
+
534
+ return PcbAssemblyGltfWriter.#triangleNormal([
535
+ points[0],
536
+ points[1],
537
+ points[2]
538
+ ])
539
+ }
540
+
541
+ /**
542
+ * Converts a source vertex to exported millimeters.
543
+ * @param {number[]} vertex Source vertex in mils.
544
+ * @returns {number[]}
545
+ */
546
+ static #exportedVertex(vertex) {
547
+ return PcbAssemblyExportCoordinateFrame.vertexMilToMm(
548
+ vertex || [0, 0, 0]
549
+ )
550
+ }
551
+
552
+ /**
553
+ * Computes a normalized triangle normal.
554
+ * @param {number[][]} points Triangle points.
555
+ * @returns {number[]}
556
+ */
557
+ static #triangleNormal(points) {
558
+ const a = points[0] || [0, 0, 0]
559
+ const b = points[1] || [0, 0, 0]
560
+ const c = points[2] || [0, 0, 0]
561
+ const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]
562
+ const ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]
563
+ const normal = [
564
+ ab[1] * ac[2] - ab[2] * ac[1],
565
+ ab[2] * ac[0] - ab[0] * ac[2],
566
+ ab[0] * ac[1] - ab[1] * ac[0]
567
+ ]
568
+ const length = Math.hypot(normal[0], normal[1], normal[2]) || 1
569
+ return normal.map((value) => value / length)
570
+ }
571
+
572
+ /**
573
+ * Computes XY bounds for texture coordinate generation.
574
+ * @param {number[][]} vertices Source vertices.
575
+ * @returns {{ minX: number, maxX: number, minY: number, maxY: number }}
576
+ */
577
+ static #meshBounds2d(vertices) {
578
+ return vertices.reduce(
579
+ (bounds, vertex) => ({
580
+ minX: Math.min(bounds.minX, Number(vertex?.[0] || 0)),
581
+ maxX: Math.max(bounds.maxX, Number(vertex?.[0] || 0)),
582
+ minY: Math.min(bounds.minY, Number(vertex?.[1] || 0)),
583
+ maxY: Math.max(bounds.maxY, Number(vertex?.[1] || 0))
584
+ }),
585
+ {
586
+ minX: Infinity,
587
+ maxX: -Infinity,
588
+ minY: Infinity,
589
+ maxY: -Infinity
590
+ }
591
+ )
592
+ }
593
+
594
+ /**
595
+ * Builds a texture coordinate from a source vertex.
596
+ * @param {number[]} vertex Source vertex.
597
+ * @param {{ minX: number, maxX: number, minY: number, maxY: number }} bounds XY bounds.
598
+ * @returns {number[]}
599
+ */
600
+ static #uvForVertex(vertex, bounds) {
601
+ const width = Math.max(bounds.maxX - bounds.minX, 0.001)
602
+ const height = Math.max(bounds.maxY - bounds.minY, 0.001)
603
+ return [
604
+ (Number(vertex?.[0] || 0) - bounds.minX) / width,
605
+ 1 - (Number(vertex?.[1] || 0) - bounds.minY) / height
606
+ ]
607
+ }
608
+
609
+ /**
610
+ * Computes accessor min/max bounds.
611
+ * @param {number[]} values Flat values.
612
+ * @param {number} width Values per element.
613
+ * @returns {{ min: number[], max: number[] }}
614
+ */
615
+ static #accessorBounds(values, width) {
616
+ const min = Array.from({ length: width }, () => Infinity)
617
+ const max = Array.from({ length: width }, () => -Infinity)
618
+
619
+ for (let index = 0; index < values.length; index += width) {
620
+ for (let offset = 0; offset < width; offset += 1) {
621
+ const value = values[index + offset]
622
+ min[offset] = Math.min(min[offset], value)
623
+ max[offset] = Math.max(max[offset], value)
624
+ }
625
+ }
626
+
627
+ return { min, max }
628
+ }
629
+
630
+ /**
631
+ * Converts RGBA input into a GLTF material color.
632
+ * @param {unknown} color Candidate color.
633
+ * @param {unknown} opacity Optional opacity override.
634
+ * @returns {number[]}
635
+ */
636
+ static #color(color, opacity = undefined) {
637
+ const fallback = [0.55, 0.56, 0.58]
638
+ if (!Array.isArray(color)) {
639
+ return [
640
+ ...fallback,
641
+ PcbAssemblyGltfWriter.#alpha(opacity, undefined)
642
+ ]
643
+ }
644
+
645
+ const rgb = [0, 1, 2].map((index) => {
646
+ const value = Number(color[index])
647
+ return Number.isFinite(value)
648
+ ? Math.min(Math.max(value, 0), 1)
649
+ : fallback[index]
650
+ })
651
+ return [...rgb, PcbAssemblyGltfWriter.#alpha(opacity, color[3])]
652
+ }
653
+
654
+ /**
655
+ * Resolves a material alpha value.
656
+ * @param {unknown} opacity Candidate opacity override.
657
+ * @param {unknown} colorAlpha Candidate color alpha.
658
+ * @returns {number}
659
+ */
660
+ static #alpha(opacity, colorAlpha) {
661
+ for (const value of [opacity, colorAlpha]) {
662
+ const number = Number(value)
663
+ if (Number.isFinite(number)) {
664
+ return Math.min(Math.max(number, 0), 1)
665
+ }
666
+ }
667
+ return 1
668
+ }
669
+
670
+ /**
671
+ * Converts float values into little-endian bytes.
672
+ * @param {number[]} values Float values.
673
+ * @returns {Uint8Array}
674
+ */
675
+ static #floatBytes(values) {
676
+ const bytes = new Uint8Array(values.length * 4)
677
+ const view = new DataView(bytes.buffer)
678
+ values.forEach((value, index) => {
679
+ view.setFloat32(index * 4, Number(value || 0), true)
680
+ })
681
+ return bytes
682
+ }
683
+
684
+ /**
685
+ * Converts unsigned integer values into little-endian bytes.
686
+ * @param {number[]} values Integer values.
687
+ * @returns {Uint8Array}
688
+ */
689
+ static #uintBytes(values) {
690
+ const bytes = new Uint8Array(values.length * 4)
691
+ const view = new DataView(bytes.buffer)
692
+ values.forEach((value, index) => {
693
+ view.setUint32(index * 4, Math.max(Number(value || 0), 0), true)
694
+ })
695
+ return bytes
696
+ }
697
+
698
+ /**
699
+ * Concatenates binary buffer parts.
700
+ * @param {Uint8Array[]} parts Buffer parts.
701
+ * @returns {Uint8Array}
702
+ */
703
+ static #concatBuffer(parts) {
704
+ const total = parts.reduce((sum, part) => sum + part.byteLength, 0)
705
+ const buffer = new Uint8Array(total)
706
+ let offset = 0
707
+
708
+ parts.forEach((part) => {
709
+ buffer.set(part, offset)
710
+ offset += part.byteLength
711
+ })
712
+
713
+ return buffer
714
+ }
715
+
716
+ /**
717
+ * Writes a GLB wrapper around GLTF JSON and binary payload.
718
+ * @param {object} gltf GLTF JSON.
719
+ * @param {Uint8Array} binaryBuffer Binary payload.
720
+ * @returns {Uint8Array}
721
+ */
722
+ static #writeGlb(gltf, binaryBuffer) {
723
+ const jsonBytes = new TextEncoder().encode(JSON.stringify(gltf))
724
+ const jsonPadding = PcbAssemblyGltfWriter.#paddingLength(
725
+ jsonBytes.byteLength
726
+ )
727
+ const binPadding = PcbAssemblyGltfWriter.#paddingLength(
728
+ binaryBuffer.byteLength
729
+ )
730
+ const jsonChunkLength = jsonBytes.byteLength + jsonPadding
731
+ const binChunkLength = binaryBuffer.byteLength + binPadding
732
+ const totalLength = 12 + 8 + jsonChunkLength + 8 + binChunkLength
733
+ const glb = new Uint8Array(totalLength)
734
+ const view = new DataView(glb.buffer)
735
+
736
+ view.setUint32(0, GLB_MAGIC, true)
737
+ view.setUint32(4, GLB_VERSION, true)
738
+ view.setUint32(8, totalLength, true)
739
+ view.setUint32(12, jsonChunkLength, true)
740
+ view.setUint32(16, JSON_CHUNK_TYPE, true)
741
+ glb.set(jsonBytes, 20)
742
+ glb.fill(0x20, 20 + jsonBytes.byteLength, 20 + jsonChunkLength)
743
+
744
+ const binHeaderOffset = 20 + jsonChunkLength
745
+ view.setUint32(binHeaderOffset, binChunkLength, true)
746
+ view.setUint32(binHeaderOffset + 4, BIN_CHUNK_TYPE, true)
747
+ glb.set(binaryBuffer, binHeaderOffset + 8)
748
+
749
+ return glb
750
+ }
751
+
752
+ /**
753
+ * Computes 4-byte alignment padding.
754
+ * @param {number} byteLength Current byte length.
755
+ * @returns {number}
756
+ */
757
+ static #paddingLength(byteLength) {
758
+ return (4 - (byteLength % 4)) % 4
759
+ }
760
+
761
+ /**
762
+ * Encodes bytes as base64 in browser and Node runtimes.
763
+ * @param {Uint8Array} bytes Binary bytes.
764
+ * @returns {string}
765
+ */
766
+ static #base64(bytes) {
767
+ if (typeof Buffer !== 'undefined') {
768
+ return Buffer.from(bytes).toString('base64')
769
+ }
770
+
771
+ let binary = ''
772
+ bytes.forEach((byte) => {
773
+ binary += String.fromCharCode(byte)
774
+ })
775
+ return btoa(binary)
776
+ }
777
+
778
+ /**
779
+ * Rounds numbers for stable mesh reuse keys.
780
+ * @param {unknown} value Candidate number.
781
+ * @returns {number}
782
+ */
783
+ static #roundedKeyNumber(value) {
784
+ const number = Number(value || 0)
785
+ return Number.isFinite(number) ? Math.round(number * 1e9) / 1e9 : 0
786
+ }
787
+
788
+ /**
789
+ * Builds a stable GLTF node or mesh name.
790
+ * @param {unknown} value Candidate name.
791
+ * @returns {string}
792
+ */
793
+ static #safeName(value) {
794
+ return (
795
+ String(value || 'mesh')
796
+ .replace(/[^A-Za-z0-9_.-]+/gu, '_')
797
+ .replace(/^_+|_+$/gu, '')
798
+ .slice(0, 80) || 'mesh'
799
+ )
800
+ }
801
+ }