pcb-scene3d-viewer 1.1.19 → 1.1.21

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.
@@ -0,0 +1,447 @@
1
+ import { PcbScene3dStepLoader } from './PcbScene3dStepLoader.mjs'
2
+
3
+ /**
4
+ * Loads resolved component 3D model assets into faceted meshes for assembly
5
+ * export.
6
+ */
7
+ export class PcbAssemblyModelMeshLoader {
8
+ /** @type {PcbScene3dStepLoader} */
9
+ #stepLoader
10
+
11
+ /**
12
+ * @param {{ stepLoader?: PcbScene3dStepLoader }} [options] Loader options.
13
+ */
14
+ constructor(options = {}) {
15
+ this.#stepLoader = options.stepLoader || new PcbScene3dStepLoader()
16
+ }
17
+
18
+ /**
19
+ * Loads one external placement into export meshes.
20
+ * @param {{ externalModel?: object, designator?: string }} placement Placement metadata.
21
+ * @returns {Promise<object[]>}
22
+ */
23
+ async loadPlacement(placement) {
24
+ const model = placement?.externalModel || null
25
+ const format = String(model?.format || '').toLowerCase()
26
+ if (format === 'step' || format === 'stp') {
27
+ return this.#loadStepMeshes(model)
28
+ }
29
+
30
+ if (format === 'wrl' || format === 'vrml') {
31
+ return this.#loadWrlMeshes(model)
32
+ }
33
+
34
+ throw new Error('Unsupported model format: ' + (format || 'unknown'))
35
+ }
36
+
37
+ /**
38
+ * Releases owned resources.
39
+ * @returns {void}
40
+ */
41
+ dispose() {
42
+ this.#stepLoader?.dispose?.()
43
+ }
44
+
45
+ /**
46
+ * Loads STEP mesh payloads through the existing viewer importer.
47
+ * @param {object} model External model metadata.
48
+ * @returns {Promise<object[]>}
49
+ */
50
+ async #loadStepMeshes(model) {
51
+ const loaded = Array.isArray(model?.preparedMeshPayloads)
52
+ ? { meshPayloads: model.preparedMeshPayloads }
53
+ : await this.#stepLoader.loadModel(model)
54
+
55
+ return PcbAssemblyModelMeshLoader.#array(loaded?.meshPayloads)
56
+ .map((payload, index) =>
57
+ PcbAssemblyModelMeshLoader.#meshesFromPayload(
58
+ payload,
59
+ model?.name || 'step-model-' + (index + 1)
60
+ )
61
+ )
62
+ .flat()
63
+ }
64
+
65
+ /**
66
+ * Converts one normalized STEP mesh payload to export mesh shape.
67
+ * @param {{ positions?: ArrayLike<number>, indices?: ArrayLike<number>, color?: number[] | null, name?: string, faceColors?: object[] }} payload STEP mesh payload.
68
+ * @param {string} fallbackName Fallback mesh name.
69
+ * @returns {object[]}
70
+ */
71
+ static #meshesFromPayload(payload, fallbackName) {
72
+ const positions = Array.from(payload?.positions || [])
73
+ const indices = Array.from(payload?.indices || [])
74
+ const vertices = []
75
+
76
+ for (let index = 0; index + 2 < positions.length; index += 3) {
77
+ vertices.push([
78
+ Number(positions[index] || 0) * 1000,
79
+ Number(positions[index + 1] || 0) * 1000,
80
+ Number(positions[index + 2] || 0) * 1000
81
+ ])
82
+ }
83
+
84
+ return PcbAssemblyModelMeshLoader.#coloredTriangleMeshes(
85
+ String(payload?.name || fallbackName || 'step-model'),
86
+ vertices,
87
+ indices,
88
+ PcbAssemblyModelMeshLoader.#normalizeColor(payload?.color),
89
+ payload?.faceColors
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Loads WRL mesh data from an asset file.
95
+ * @param {object} model External model metadata.
96
+ * @returns {Promise<object[]>}
97
+ */
98
+ async #loadWrlMeshes(model) {
99
+ const text = await PcbAssemblyModelMeshLoader.#readModelText(model)
100
+ const meshes = PcbAssemblyModelMeshLoader.#parseWrlIndexedFaceSets(text)
101
+ if (!meshes.length) {
102
+ throw new Error('No IndexedFaceSet geometry was found in WRL.')
103
+ }
104
+
105
+ return meshes.map((mesh, index) => ({
106
+ ...mesh,
107
+ name: mesh.name || String(model?.name || 'wrl-model-' + (index + 1))
108
+ }))
109
+ }
110
+
111
+ /**
112
+ * Reads a model as UTF-8 text.
113
+ * @param {{ payloadText?: string, file?: Blob | Uint8Array | null }} model Model metadata.
114
+ * @returns {Promise<string>}
115
+ */
116
+ static async #readModelText(model) {
117
+ if (typeof model?.payloadText === 'string') {
118
+ return model.payloadText
119
+ }
120
+
121
+ if (typeof model?.file?.text === 'function') {
122
+ return await model.file.text()
123
+ }
124
+
125
+ if (typeof model?.file?.arrayBuffer === 'function') {
126
+ return new TextDecoder().decode(
127
+ new Uint8Array(await model.file.arrayBuffer())
128
+ )
129
+ }
130
+
131
+ if (model?.file instanceof Uint8Array) {
132
+ return new TextDecoder().decode(model.file)
133
+ }
134
+
135
+ throw new Error('WRL model content is not available.')
136
+ }
137
+
138
+ /**
139
+ * Parses simple VRML IndexedFaceSet geometry blocks.
140
+ * @param {string} text WRL source.
141
+ * @returns {object[]}
142
+ */
143
+ static #parseWrlIndexedFaceSets(text) {
144
+ const pointBlocks = PcbAssemblyModelMeshLoader.#matchBlocks(
145
+ text,
146
+ /Coordinate\s*\{[\s\S]*?point\s*\[([\s\S]*?)\]/giu
147
+ )
148
+ const indexBlocks = PcbAssemblyModelMeshLoader.#matchBlocks(
149
+ text,
150
+ /coordIndex\s*\[([\s\S]*?)\]/giu
151
+ )
152
+ const colors = PcbAssemblyModelMeshLoader.#parseWrlDiffuseColors(text)
153
+ const meshCount = Math.min(pointBlocks.length, indexBlocks.length)
154
+ const meshes = []
155
+
156
+ for (let index = 0; index < meshCount; index += 1) {
157
+ const vertices = PcbAssemblyModelMeshLoader.#parseTriplets(
158
+ pointBlocks[index]
159
+ )
160
+ const faces = PcbAssemblyModelMeshLoader.#parseCoordIndex(
161
+ indexBlocks[index]
162
+ )
163
+ if (vertices.length && faces.length) {
164
+ meshes.push({
165
+ name: 'wrl-mesh-' + (index + 1),
166
+ vertices,
167
+ faces,
168
+ ...(colors[index] ? { color: colors[index] } : {})
169
+ })
170
+ }
171
+ }
172
+
173
+ return meshes
174
+ }
175
+
176
+ /**
177
+ * Returns regex capture groups.
178
+ * @param {string} text Source text.
179
+ * @param {RegExp} pattern Capture pattern.
180
+ * @returns {string[]}
181
+ */
182
+ static #matchBlocks(text, pattern) {
183
+ return [...String(text || '').matchAll(pattern)].map(
184
+ (match) => match[1] || ''
185
+ )
186
+ }
187
+
188
+ /**
189
+ * Parses numeric triplets.
190
+ * @param {string} text Numeric source.
191
+ * @returns {number[][]}
192
+ */
193
+ static #parseTriplets(text) {
194
+ const values = PcbAssemblyModelMeshLoader.#numbers(text)
195
+ const triplets = []
196
+
197
+ for (let index = 0; index + 2 < values.length; index += 3) {
198
+ triplets.push([values[index], values[index + 1], values[index + 2]])
199
+ }
200
+
201
+ return triplets
202
+ }
203
+
204
+ /**
205
+ * Parses VRML coordIndex face lists.
206
+ * @param {string} text Numeric source.
207
+ * @returns {number[][]}
208
+ */
209
+ static #parseCoordIndex(text) {
210
+ const values = PcbAssemblyModelMeshLoader.#numbers(text)
211
+ const faces = []
212
+ let face = []
213
+
214
+ values.forEach((value) => {
215
+ if (Number(value) === -1) {
216
+ if (face.length >= 3) {
217
+ faces.push(face)
218
+ }
219
+ face = []
220
+ return
221
+ }
222
+ face.push(Number(value))
223
+ })
224
+
225
+ if (face.length >= 3) {
226
+ faces.push(face)
227
+ }
228
+
229
+ return faces
230
+ }
231
+
232
+ /**
233
+ * Builds export meshes grouped by normalized RGB color.
234
+ * @param {string} name Base mesh name.
235
+ * @param {number[][]} vertices Source vertices.
236
+ * @param {number[]} indices Source triangle indices.
237
+ * @param {number[] | null} defaultColor Default mesh color.
238
+ * @param {object[] | undefined} faceColors Face-color ranges.
239
+ * @returns {object[]}
240
+ */
241
+ static #coloredTriangleMeshes(
242
+ name,
243
+ vertices,
244
+ indices,
245
+ defaultColor,
246
+ faceColors
247
+ ) {
248
+ const groups = []
249
+ const normalizedFaceColors =
250
+ PcbAssemblyModelMeshLoader.#normalizeFaceColors(
251
+ faceColors,
252
+ Math.floor(indices.length / 3)
253
+ )
254
+ let faceColorIndex = 0
255
+
256
+ for (let index = 0; index + 2 < indices.length; index += 3) {
257
+ const triangleIndex = index / 3
258
+ while (
259
+ normalizedFaceColors[faceColorIndex] &&
260
+ triangleIndex > normalizedFaceColors[faceColorIndex].last
261
+ ) {
262
+ faceColorIndex += 1
263
+ }
264
+
265
+ const activeFaceColor = normalizedFaceColors[faceColorIndex]
266
+ const color =
267
+ activeFaceColor &&
268
+ triangleIndex >= activeFaceColor.first &&
269
+ triangleIndex <= activeFaceColor.last
270
+ ? activeFaceColor.color || defaultColor
271
+ : defaultColor
272
+ const group = PcbAssemblyModelMeshLoader.#resolveColorGroup(
273
+ groups,
274
+ color
275
+ )
276
+ const face = []
277
+
278
+ for (const sourceIndex of indices.slice(index, index + 3)) {
279
+ face.push(
280
+ PcbAssemblyModelMeshLoader.#appendGroupVertex(
281
+ group,
282
+ vertices,
283
+ sourceIndex
284
+ )
285
+ )
286
+ }
287
+
288
+ group.faces.push(face)
289
+ }
290
+
291
+ return groups
292
+ .filter((group) => group.faces.length)
293
+ .map((group, index) => ({
294
+ name: groups.length > 1 ? name + '-' + (index + 1) : name,
295
+ vertices: group.vertices,
296
+ faces: group.faces,
297
+ ...(group.color ? { color: group.color } : {})
298
+ }))
299
+ }
300
+
301
+ /**
302
+ * Resolves a mutable color group.
303
+ * @param {object[]} groups Existing groups.
304
+ * @param {number[] | null} color RGB color.
305
+ * @returns {object}
306
+ */
307
+ static #resolveColorGroup(groups, color) {
308
+ const key = PcbAssemblyModelMeshLoader.#colorKey(color)
309
+ let group = groups.find((entry) => entry.key === key)
310
+
311
+ if (!group) {
312
+ group = {
313
+ key,
314
+ color,
315
+ indexMap: new Map(),
316
+ vertices: [],
317
+ faces: []
318
+ }
319
+ groups.push(group)
320
+ }
321
+
322
+ return group
323
+ }
324
+
325
+ /**
326
+ * Appends or reuses one source vertex in a color group.
327
+ * @param {object} group Mutable color group.
328
+ * @param {number[][]} vertices Source vertices.
329
+ * @param {unknown} sourceIndex Source vertex index.
330
+ * @returns {number}
331
+ */
332
+ static #appendGroupVertex(group, vertices, sourceIndex) {
333
+ const index = Number(sourceIndex || 0)
334
+ const existing = group.indexMap.get(index)
335
+ if (Number.isInteger(existing)) {
336
+ return existing
337
+ }
338
+
339
+ const vertexIndex = group.vertices.length
340
+ group.indexMap.set(index, vertexIndex)
341
+ group.vertices.push(vertices[index] || [0, 0, 0])
342
+ return vertexIndex
343
+ }
344
+
345
+ /**
346
+ * Normalizes valid face-color ranges.
347
+ * @param {object[] | undefined} faceColors Source ranges.
348
+ * @param {number} triangleCount Number of triangles in the payload.
349
+ * @returns {{ first: number, last: number, color: number[] | null }[]}
350
+ */
351
+ static #normalizeFaceColors(faceColors, triangleCount) {
352
+ return PcbAssemblyModelMeshLoader.#array(faceColors)
353
+ .map((faceColor) => {
354
+ const first = Number(faceColor?.first)
355
+ const last = Number(faceColor?.last)
356
+
357
+ if (
358
+ !Number.isInteger(first) ||
359
+ !Number.isInteger(last) ||
360
+ first < 0 ||
361
+ last < first ||
362
+ first >= triangleCount
363
+ ) {
364
+ return null
365
+ }
366
+
367
+ return {
368
+ first,
369
+ last: Math.min(last, triangleCount - 1),
370
+ color: PcbAssemblyModelMeshLoader.#normalizeColor(
371
+ faceColor?.color
372
+ )
373
+ }
374
+ })
375
+ .filter(Boolean)
376
+ .sort((left, right) => left.first - right.first)
377
+ }
378
+
379
+ /**
380
+ * Parses VRML material diffuse colors in source order.
381
+ * @param {string} text VRML source.
382
+ * @returns {number[][]}
383
+ */
384
+ static #parseWrlDiffuseColors(text) {
385
+ return [
386
+ ...String(text || '').matchAll(
387
+ /diffuseColor\s+([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s+([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\s+([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)/giu
388
+ )
389
+ ]
390
+ .map((match) =>
391
+ PcbAssemblyModelMeshLoader.#normalizeColor([
392
+ Number(match[1]),
393
+ Number(match[2]),
394
+ Number(match[3])
395
+ ])
396
+ )
397
+ .filter(Boolean)
398
+ }
399
+
400
+ /**
401
+ * Normalizes an RGB color to clamped components.
402
+ * @param {unknown} color Candidate color.
403
+ * @returns {number[] | null}
404
+ */
405
+ static #normalizeColor(color) {
406
+ if (!Array.isArray(color) || color.length < 3) {
407
+ return null
408
+ }
409
+
410
+ return [0, 1, 2].map((index) =>
411
+ Math.max(Math.min(Number(color[index] || 0), 1), 0)
412
+ )
413
+ }
414
+
415
+ /**
416
+ * Builds a stable color-group key.
417
+ * @param {number[] | null} color RGB color.
418
+ * @returns {string}
419
+ */
420
+ static #colorKey(color) {
421
+ return Array.isArray(color)
422
+ ? color.map((value) => value.toFixed(6)).join(',')
423
+ : 'default'
424
+ }
425
+
426
+ /**
427
+ * Parses all numbers from source text.
428
+ * @param {string} text Numeric source.
429
+ * @returns {number[]}
430
+ */
431
+ static #numbers(text) {
432
+ return (
433
+ String(text || '').match(/[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/gu) || []
434
+ )
435
+ .map((value) => Number(value))
436
+ .filter((value) => Number.isFinite(value))
437
+ }
438
+
439
+ /**
440
+ * Normalizes a value to an array.
441
+ * @param {unknown} value Candidate value.
442
+ * @returns {any[]}
443
+ */
444
+ static #array(value) {
445
+ return Array.isArray(value) ? value : []
446
+ }
447
+ }