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.
- package/README.md +3 -2
- package/docs/api.md +27 -1
- package/docs/circuitjson.md +63 -18
- package/docs/model-format.md +13 -2
- package/package.json +1 -1
- package/spec/library-scope.md +2 -2
- package/src/PcbAssemblyBoardSubstrateBuilder.mjs +25 -5
- package/src/PcbAssemblyComponentMeshBuilder.mjs +762 -0
- package/src/PcbAssemblyFillGeometryResolver.mjs +579 -0
- package/src/PcbAssemblyFillRingNormalizer.mjs +209 -0
- package/src/PcbAssemblyGeometryBuilder.mjs +41 -301
- package/src/PcbAssemblyGltfModelMeshParser.mjs +912 -0
- package/src/PcbAssemblyGltfValidator.mjs +460 -0
- package/src/PcbAssemblyGltfWriter.mjs +801 -0
- package/src/PcbAssemblyMeshUtils.mjs +117 -0
- package/src/PcbAssemblyModelMeshLoader.mjs +18 -0
- package/src/PcbAssemblyPadMeshBuilder.mjs +394 -0
- package/src/PcbAssemblyStepWriter.mjs +24 -2
- package/src/PcbAssemblyTextModelMeshParser.mjs +602 -0
- package/src/PcbModelArchiveExporter.mjs +521 -7
- package/src/PcbScene3dCircuitJsonAdapter.mjs +409 -7
- package/src/PcbScene3dCircuitJsonModelTransform.mjs +232 -0
- package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +242 -0
- package/src/PcbScene3dComponentVisibility.mjs +100 -5
- package/src/PcbScene3dController.mjs +25 -55
- package/src/PcbScene3dCopperDetailFilter.mjs +86 -9
- package/src/PcbScene3dCopperDetailGroupBuilder.mjs +186 -15
- package/src/PcbScene3dCopperDrillCutoutBuilder.mjs +258 -0
- package/src/PcbScene3dCopperFactory.mjs +99 -85
- package/src/PcbScene3dCopperFillMeshBuilder.mjs +393 -0
- package/src/PcbScene3dCopperLayerFilter.mjs +32 -3
- package/src/PcbScene3dCutoutGeometryFilter.mjs +17 -14
- package/src/PcbScene3dExternalCompanionFallback.mjs +202 -0
- package/src/PcbScene3dExternalModelCenteringPolicy.mjs +21 -0
- package/src/PcbScene3dExternalModelOpacity.mjs +51 -0
- package/src/PcbScene3dExternalModelPlacementRepair.mjs +5 -2
- package/src/PcbScene3dExternalModelSourceOriginPolicy.mjs +41 -0
- package/src/PcbScene3dExternalModels.mjs +16 -7
- package/src/PcbScene3dFallbackBodyFactory.mjs +105 -0
- package/src/PcbScene3dFallbackVisibility.mjs +58 -1
- package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +68 -0
- package/src/PcbScene3dPadFactory.mjs +35 -2
- package/src/PcbScene3dRenderGroupVisibility.mjs +8 -1
- package/src/PcbScene3dRuntime.mjs +94 -100
- package/src/PcbScene3dRuntimeHelpers.mjs +39 -0
- package/src/PcbScene3dSelectionIndexBuilder.mjs +87 -0
- package/src/PcbScene3dSelectionInspectorRenderer.mjs +50 -11
- package/src/PcbScene3dSelectionMarkerFactory.mjs +333 -0
- package/src/PcbScene3dSelectionMarkerOverlay.mjs +70 -0
- package/src/PcbScene3dSelectionStyler.mjs +52 -2
- package/src/PcbScene3dStaticBodyFactory.mjs +263 -0
- package/src/PcbScene3dText.mjs +1 -0
- package/src/PcbScene3dTransparentMeshSplitter.mjs +623 -0
- package/src/PcbScene3dViaFactory.mjs +27 -7
- 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
|
+
}
|