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.
- package/package.json +3 -2
- package/src/PcbAssemblyBoardSubstrateBuilder.mjs +467 -0
- package/src/PcbAssemblyExportCoordinateFrame.mjs +19 -0
- package/src/PcbAssemblyGeometryBuildProgress.mjs +201 -0
- package/src/PcbAssemblyGeometryBuilder.mjs +994 -0
- package/src/PcbAssemblyMeshUtils.mjs +541 -0
- package/src/PcbAssemblyModelMeshLoader.mjs +447 -0
- package/src/PcbAssemblyPolygonTriangulator.mjs +301 -0
- package/src/PcbAssemblyStepWriter.mjs +952 -0
- package/src/PcbAssemblyWrlWriter.mjs +144 -0
- package/src/PcbScene3dCameraRig.mjs +11 -5
- package/src/PcbScene3dComponentVisibility.mjs +161 -0
- package/src/PcbScene3dController.mjs +29 -4
- package/src/PcbScene3dExternalModelPlacementRepair.mjs +1 -1
- package/src/PcbScene3dInteractionHints.mjs +14 -6
- package/src/PcbScene3dRenderScheduler.mjs +55 -0
- package/src/PcbScene3dRuntime.mjs +56 -4
- package/src/PcbScene3dSelectionInspectorRenderer.mjs +64 -3
- package/src/PcbScene3dSelectionVisibilityBinder.mjs +119 -0
- package/src/PcbScene3dText.mjs +4 -2
- package/src/scene3d.mjs +12 -0
- package/src/styles/scene3d.css +48 -0
|
@@ -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
|
+
}
|