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,602 @@
|
|
|
1
|
+
const MM_TO_MIL = 1000 / 25.4
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses simple text-based triangle model formats into assembly meshes.
|
|
5
|
+
*/
|
|
6
|
+
export class PcbAssemblyTextModelMeshParser {
|
|
7
|
+
/**
|
|
8
|
+
* Parses one STL model from text or binary model metadata.
|
|
9
|
+
* @param {{ name?: string, payloadText?: string, file?: any }} model Model metadata.
|
|
10
|
+
* @returns {Promise<object[]>}
|
|
11
|
+
*/
|
|
12
|
+
static async parseStlModel(model) {
|
|
13
|
+
const bytes = await PcbAssemblyTextModelMeshParser.#readModelBytes(
|
|
14
|
+
model,
|
|
15
|
+
'STL'
|
|
16
|
+
)
|
|
17
|
+
const text = new TextDecoder().decode(bytes)
|
|
18
|
+
const meshes = PcbAssemblyTextModelMeshParser.#isBinaryStl(bytes, text)
|
|
19
|
+
? PcbAssemblyTextModelMeshParser.#parseBinaryStl(bytes, model)
|
|
20
|
+
: PcbAssemblyTextModelMeshParser.#parseAsciiStl(text, model)
|
|
21
|
+
|
|
22
|
+
if (!meshes.length) {
|
|
23
|
+
throw new Error('No triangle geometry was found in STL.')
|
|
24
|
+
}
|
|
25
|
+
return meshes
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parses one OBJ model from text model metadata.
|
|
30
|
+
* @param {{ name?: string, payloadText?: string, file?: any }} model Model metadata.
|
|
31
|
+
* @returns {Promise<object[]>}
|
|
32
|
+
*/
|
|
33
|
+
static async parseObjModel(model) {
|
|
34
|
+
const text = await PcbAssemblyTextModelMeshParser.#readModelText(
|
|
35
|
+
model,
|
|
36
|
+
'OBJ'
|
|
37
|
+
)
|
|
38
|
+
const vertices = []
|
|
39
|
+
const faceGroups = new Map()
|
|
40
|
+
const materialLibraries = []
|
|
41
|
+
let activeMaterial = ''
|
|
42
|
+
|
|
43
|
+
String(text || '')
|
|
44
|
+
.split(/\r?\n/u)
|
|
45
|
+
.forEach((line) => {
|
|
46
|
+
const trimmed = line.replace(/#.*/u, '').trim()
|
|
47
|
+
if (trimmed.startsWith('mtllib ')) {
|
|
48
|
+
materialLibraries.push(
|
|
49
|
+
...trimmed.slice(7).trim().split(/\s+/u).filter(Boolean)
|
|
50
|
+
)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (trimmed.startsWith('usemtl ')) {
|
|
55
|
+
activeMaterial = trimmed.slice(7).trim()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (trimmed.startsWith('v ')) {
|
|
60
|
+
vertices.push(
|
|
61
|
+
PcbAssemblyTextModelMeshParser.#toMilTriplet(
|
|
62
|
+
trimmed.slice(2)
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (trimmed.startsWith('f ')) {
|
|
69
|
+
const face = PcbAssemblyTextModelMeshParser.#parseObjFace(
|
|
70
|
+
trimmed.slice(2),
|
|
71
|
+
vertices.length
|
|
72
|
+
)
|
|
73
|
+
if (face.length >= 3) {
|
|
74
|
+
PcbAssemblyTextModelMeshParser.#facesForMaterial(
|
|
75
|
+
faceGroups,
|
|
76
|
+
activeMaterial
|
|
77
|
+
).push(face)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const meshGroups = Array.from(faceGroups.entries()).filter(
|
|
83
|
+
(entry) => entry[1].length
|
|
84
|
+
)
|
|
85
|
+
if (!vertices.length || !meshGroups.length) {
|
|
86
|
+
throw new Error('No polygon geometry was found in OBJ.')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const materials =
|
|
90
|
+
await PcbAssemblyTextModelMeshParser.#objMaterialColors(
|
|
91
|
+
model,
|
|
92
|
+
materialLibraries
|
|
93
|
+
)
|
|
94
|
+
const baseName = PcbAssemblyTextModelMeshParser.#modelName(
|
|
95
|
+
model,
|
|
96
|
+
'obj-model'
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return meshGroups.map(([materialName, faces], index) => ({
|
|
100
|
+
name: PcbAssemblyTextModelMeshParser.#objMeshName(
|
|
101
|
+
baseName,
|
|
102
|
+
materialName,
|
|
103
|
+
meshGroups.length,
|
|
104
|
+
index
|
|
105
|
+
),
|
|
106
|
+
vertices,
|
|
107
|
+
faces,
|
|
108
|
+
...PcbAssemblyTextModelMeshParser.#objMaterialProperties(
|
|
109
|
+
materials.get(materialName)
|
|
110
|
+
)
|
|
111
|
+
}))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parses ASCII STL facet vertices.
|
|
116
|
+
* @param {string} text STL source.
|
|
117
|
+
* @param {{ name?: string }} model Model metadata.
|
|
118
|
+
* @returns {object[]}
|
|
119
|
+
*/
|
|
120
|
+
static #parseAsciiStl(text, model) {
|
|
121
|
+
const vertices = []
|
|
122
|
+
const faces = []
|
|
123
|
+
const pending = []
|
|
124
|
+
|
|
125
|
+
for (const match of String(text || '').matchAll(
|
|
126
|
+
/^\s*vertex\s+(.+)$/gimu
|
|
127
|
+
)) {
|
|
128
|
+
pending.push(
|
|
129
|
+
PcbAssemblyTextModelMeshParser.#toMilTriplet(match[1] || '')
|
|
130
|
+
)
|
|
131
|
+
if (pending.length === 3) {
|
|
132
|
+
const firstIndex = vertices.length
|
|
133
|
+
vertices.push(...pending.splice(0, 3))
|
|
134
|
+
faces.push([firstIndex, firstIndex + 1, firstIndex + 2])
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return vertices.length
|
|
139
|
+
? [
|
|
140
|
+
{
|
|
141
|
+
name: PcbAssemblyTextModelMeshParser.#modelName(
|
|
142
|
+
model,
|
|
143
|
+
'stl-model'
|
|
144
|
+
),
|
|
145
|
+
vertices,
|
|
146
|
+
faces
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
: []
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parses binary STL facet vertices.
|
|
154
|
+
* @param {Uint8Array} bytes STL bytes.
|
|
155
|
+
* @param {{ name?: string }} model Model metadata.
|
|
156
|
+
* @returns {object[]}
|
|
157
|
+
*/
|
|
158
|
+
static #parseBinaryStl(bytes, model) {
|
|
159
|
+
if (bytes.byteLength < 84) {
|
|
160
|
+
return []
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const view = new DataView(
|
|
164
|
+
bytes.buffer,
|
|
165
|
+
bytes.byteOffset,
|
|
166
|
+
bytes.byteLength
|
|
167
|
+
)
|
|
168
|
+
const triangleCount = view.getUint32(80, true)
|
|
169
|
+
const vertices = []
|
|
170
|
+
const faces = []
|
|
171
|
+
|
|
172
|
+
for (let triangle = 0; triangle < triangleCount; triangle += 1) {
|
|
173
|
+
const offset = 84 + triangle * 50
|
|
174
|
+
if (offset + 50 > bytes.byteLength) {
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const firstIndex = vertices.length
|
|
179
|
+
for (let vertex = 0; vertex < 3; vertex += 1) {
|
|
180
|
+
const vertexOffset = offset + 12 + vertex * 12
|
|
181
|
+
vertices.push([
|
|
182
|
+
view.getFloat32(vertexOffset, true) * MM_TO_MIL,
|
|
183
|
+
view.getFloat32(vertexOffset + 4, true) * MM_TO_MIL,
|
|
184
|
+
view.getFloat32(vertexOffset + 8, true) * MM_TO_MIL
|
|
185
|
+
])
|
|
186
|
+
}
|
|
187
|
+
faces.push([firstIndex, firstIndex + 1, firstIndex + 2])
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return vertices.length
|
|
191
|
+
? [
|
|
192
|
+
{
|
|
193
|
+
name: PcbAssemblyTextModelMeshParser.#modelName(
|
|
194
|
+
model,
|
|
195
|
+
'stl-model'
|
|
196
|
+
),
|
|
197
|
+
vertices,
|
|
198
|
+
faces
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
: []
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Returns true when bytes look like binary STL instead of ASCII STL.
|
|
206
|
+
* @param {Uint8Array} bytes STL bytes.
|
|
207
|
+
* @param {string} text UTF-8 decoded source.
|
|
208
|
+
* @returns {boolean}
|
|
209
|
+
*/
|
|
210
|
+
static #isBinaryStl(bytes, text) {
|
|
211
|
+
if (bytes.byteLength < 84) {
|
|
212
|
+
return false
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const view = new DataView(
|
|
216
|
+
bytes.buffer,
|
|
217
|
+
bytes.byteOffset,
|
|
218
|
+
bytes.byteLength
|
|
219
|
+
)
|
|
220
|
+
const triangleCount = view.getUint32(80, true)
|
|
221
|
+
const expectedLength = 84 + triangleCount * 50
|
|
222
|
+
if (expectedLength === bytes.byteLength) {
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return !/^\s*solid\b/iu.test(String(text || ''))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Parses one OBJ face line into zero-based vertex indexes.
|
|
231
|
+
* @param {string} text OBJ face source.
|
|
232
|
+
* @param {number} vertexCount Number of known vertices.
|
|
233
|
+
* @returns {number[]}
|
|
234
|
+
*/
|
|
235
|
+
static #parseObjFace(text, vertexCount) {
|
|
236
|
+
return String(text || '')
|
|
237
|
+
.trim()
|
|
238
|
+
.split(/\s+/u)
|
|
239
|
+
.map((token) => Number(token.split('/')[0]))
|
|
240
|
+
.filter((index) => Number.isInteger(index) && index !== 0)
|
|
241
|
+
.map((index) =>
|
|
242
|
+
index < 0 ? vertexCount + index : Math.max(index - 1, 0)
|
|
243
|
+
)
|
|
244
|
+
.filter((index) => index >= 0 && index < vertexCount)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Returns the mutable face list for an OBJ material name.
|
|
249
|
+
* @param {Map<string, number[][]>} faceGroups Mutable face groups.
|
|
250
|
+
* @param {string} materialName Active material name.
|
|
251
|
+
* @returns {number[][]}
|
|
252
|
+
*/
|
|
253
|
+
static #facesForMaterial(faceGroups, materialName) {
|
|
254
|
+
const key = String(materialName || '')
|
|
255
|
+
if (!faceGroups.has(key)) {
|
|
256
|
+
faceGroups.set(key, [])
|
|
257
|
+
}
|
|
258
|
+
return faceGroups.get(key)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Reads and parses declared OBJ material libraries.
|
|
263
|
+
* @param {object} model Model metadata.
|
|
264
|
+
* @param {string[]} materialLibraries Declared MTL file names.
|
|
265
|
+
* @returns {Promise<Map<string, number[]>>}
|
|
266
|
+
*/
|
|
267
|
+
static async #objMaterialColors(model, materialLibraries) {
|
|
268
|
+
const materials = new Map()
|
|
269
|
+
for (const library of materialLibraries) {
|
|
270
|
+
const text = await PcbAssemblyTextModelMeshParser.#readResourceText(
|
|
271
|
+
library,
|
|
272
|
+
model
|
|
273
|
+
)
|
|
274
|
+
PcbAssemblyTextModelMeshParser.#parseObjMaterialLibrary(
|
|
275
|
+
text,
|
|
276
|
+
materials
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
return materials
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Parses one MTL material library into color entries.
|
|
284
|
+
* @param {string | null} text MTL source text.
|
|
285
|
+
* @param {Map<string, number[]>} materials Mutable material color map.
|
|
286
|
+
* @returns {void}
|
|
287
|
+
*/
|
|
288
|
+
static #parseObjMaterialLibrary(text, materials) {
|
|
289
|
+
let activeName = ''
|
|
290
|
+
let active = null
|
|
291
|
+
|
|
292
|
+
String(text || '')
|
|
293
|
+
.split(/\r?\n/u)
|
|
294
|
+
.forEach((line) => {
|
|
295
|
+
const trimmed = line.replace(/#.*/u, '').trim()
|
|
296
|
+
if (trimmed.startsWith('newmtl ')) {
|
|
297
|
+
activeName = trimmed.slice(7).trim()
|
|
298
|
+
active = { rgb: null, alpha: 1 }
|
|
299
|
+
if (activeName) {
|
|
300
|
+
materials.set(activeName, [0.55, 0.56, 0.58, 1])
|
|
301
|
+
}
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!active || !activeName) {
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (trimmed.startsWith('Kd ')) {
|
|
310
|
+
active.rgb = PcbAssemblyTextModelMeshParser.#unitTriple(
|
|
311
|
+
trimmed.slice(3)
|
|
312
|
+
)
|
|
313
|
+
} else if (trimmed.startsWith('d ')) {
|
|
314
|
+
active.alpha = PcbAssemblyTextModelMeshParser.#clampUnit(
|
|
315
|
+
Number(trimmed.slice(2))
|
|
316
|
+
)
|
|
317
|
+
} else if (trimmed.startsWith('Tr ')) {
|
|
318
|
+
active.alpha =
|
|
319
|
+
1 -
|
|
320
|
+
PcbAssemblyTextModelMeshParser.#clampUnit(
|
|
321
|
+
Number(trimmed.slice(3))
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (active.rgb) {
|
|
326
|
+
materials.set(activeName, [...active.rgb, active.alpha])
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Resolves material properties for an OBJ face group.
|
|
333
|
+
* @param {number[] | undefined} color Material color.
|
|
334
|
+
* @returns {{ color?: number[] }}
|
|
335
|
+
*/
|
|
336
|
+
static #objMaterialProperties(color) {
|
|
337
|
+
return Array.isArray(color) && color.length >= 3 ? { color } : {}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Builds a stable OBJ mesh name.
|
|
342
|
+
* @param {string} baseName Base model name.
|
|
343
|
+
* @param {string} materialName Material group name.
|
|
344
|
+
* @param {number} meshCount Number of mesh groups.
|
|
345
|
+
* @param {number} meshIndex Mesh group index.
|
|
346
|
+
* @returns {string}
|
|
347
|
+
*/
|
|
348
|
+
static #objMeshName(baseName, materialName, meshCount, meshIndex) {
|
|
349
|
+
if (meshCount <= 1) {
|
|
350
|
+
return baseName
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const suffix =
|
|
354
|
+
PcbAssemblyTextModelMeshParser.#safeName(materialName) ||
|
|
355
|
+
String(meshIndex + 1)
|
|
356
|
+
return baseName + '-' + suffix
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Parses a unit RGB triplet.
|
|
361
|
+
* @param {string} text Numeric triplet source.
|
|
362
|
+
* @returns {number[]}
|
|
363
|
+
*/
|
|
364
|
+
static #unitTriple(text) {
|
|
365
|
+
const values =
|
|
366
|
+
String(text || '').match(/[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/gu) || []
|
|
367
|
+
return [0, 1, 2].map((index) =>
|
|
368
|
+
PcbAssemblyTextModelMeshParser.#clampUnit(values[index])
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Clamps a value into the unit interval.
|
|
374
|
+
* @param {unknown} value Candidate number.
|
|
375
|
+
* @param {number} [fallback] Fallback value.
|
|
376
|
+
* @returns {number}
|
|
377
|
+
*/
|
|
378
|
+
static #clampUnit(value, fallback = 0) {
|
|
379
|
+
const number = Number(value)
|
|
380
|
+
return Number.isFinite(number)
|
|
381
|
+
? Math.min(Math.max(number, 0), 1)
|
|
382
|
+
: fallback
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Converts a numeric triplet from millimeters into internal mils.
|
|
387
|
+
* @param {string} text Numeric triplet source.
|
|
388
|
+
* @returns {number[]}
|
|
389
|
+
*/
|
|
390
|
+
static #toMilTriplet(text) {
|
|
391
|
+
const values =
|
|
392
|
+
String(text || '').match(/[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/gu) || []
|
|
393
|
+
return [0, 1, 2].map((index) => Number(values[index] || 0) * MM_TO_MIL)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Reads model metadata as UTF-8 text.
|
|
398
|
+
* @param {{ payloadText?: string, file?: any }} model Model metadata.
|
|
399
|
+
* @param {string} label Format label.
|
|
400
|
+
* @returns {Promise<string>}
|
|
401
|
+
*/
|
|
402
|
+
static async #readModelText(model, label) {
|
|
403
|
+
if (typeof model?.payloadText === 'string') {
|
|
404
|
+
return model.payloadText
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (typeof model?.file?.text === 'function') {
|
|
408
|
+
return await model.file.text()
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return new TextDecoder().decode(
|
|
412
|
+
await PcbAssemblyTextModelMeshParser.#readModelBytes(model, label)
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Reads model metadata as bytes.
|
|
418
|
+
* @param {{ payloadText?: string, payloadBytes?: any, bytes?: any, file?: any }} model Model metadata.
|
|
419
|
+
* @param {string} label Format label.
|
|
420
|
+
* @returns {Promise<Uint8Array>}
|
|
421
|
+
*/
|
|
422
|
+
static async #readModelBytes(model, label) {
|
|
423
|
+
const bytes = await PcbAssemblyTextModelMeshParser.#readAnyBytes(model)
|
|
424
|
+
if (bytes) {
|
|
425
|
+
return bytes
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
throw new Error(label + ' model content is not available.')
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Reads a sidecar resource as UTF-8 text.
|
|
433
|
+
* @param {string} uri Resource URI from the OBJ file.
|
|
434
|
+
* @param {object} model Model metadata.
|
|
435
|
+
* @returns {Promise<string | null>}
|
|
436
|
+
*/
|
|
437
|
+
static async #readResourceText(uri, model) {
|
|
438
|
+
const normalizedUri = PcbAssemblyTextModelMeshParser.#normalizePath(uri)
|
|
439
|
+
const uriName = normalizedUri.split('/').pop()
|
|
440
|
+
|
|
441
|
+
for (const resource of PcbAssemblyTextModelMeshParser.#resources(
|
|
442
|
+
model
|
|
443
|
+
)) {
|
|
444
|
+
const names =
|
|
445
|
+
PcbAssemblyTextModelMeshParser.#resourceNames(resource)
|
|
446
|
+
if (
|
|
447
|
+
names.some(
|
|
448
|
+
(name) =>
|
|
449
|
+
name === normalizedUri ||
|
|
450
|
+
name.endsWith('/' + normalizedUri) ||
|
|
451
|
+
name.split('/').pop() === uriName
|
|
452
|
+
)
|
|
453
|
+
) {
|
|
454
|
+
const bytes =
|
|
455
|
+
await PcbAssemblyTextModelMeshParser.#readAnyBytes(resource)
|
|
456
|
+
if (bytes) {
|
|
457
|
+
return new TextDecoder().decode(bytes)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return null
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Collects sidecar resource candidates from model metadata.
|
|
467
|
+
* @param {object} model Model metadata.
|
|
468
|
+
* @returns {object[]}
|
|
469
|
+
*/
|
|
470
|
+
static #resources(model) {
|
|
471
|
+
const resources = []
|
|
472
|
+
;[
|
|
473
|
+
model?.resources,
|
|
474
|
+
model?.relatedFiles,
|
|
475
|
+
model?.assets,
|
|
476
|
+
model?.files,
|
|
477
|
+
model?.externalBuffers,
|
|
478
|
+
model?.bufferFiles
|
|
479
|
+
].forEach((value) => {
|
|
480
|
+
if (Array.isArray(value)) {
|
|
481
|
+
resources.push(...value)
|
|
482
|
+
} else if (value && typeof value === 'object') {
|
|
483
|
+
Object.entries(value).forEach(([name, entry]) => {
|
|
484
|
+
resources.push(
|
|
485
|
+
typeof entry === 'string'
|
|
486
|
+
? { name, payloadText: entry }
|
|
487
|
+
: { name, ...(entry || {}) }
|
|
488
|
+
)
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
})
|
|
492
|
+
return resources
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Returns normalized resource names used for URI matching.
|
|
497
|
+
* @param {object} resource Resource metadata.
|
|
498
|
+
* @returns {string[]}
|
|
499
|
+
*/
|
|
500
|
+
static #resourceNames(resource) {
|
|
501
|
+
return [
|
|
502
|
+
resource?.uri,
|
|
503
|
+
resource?.relativePath,
|
|
504
|
+
resource?.name,
|
|
505
|
+
resource?.file?.name,
|
|
506
|
+
resource?.sourceUrl
|
|
507
|
+
]
|
|
508
|
+
.map((value) =>
|
|
509
|
+
PcbAssemblyTextModelMeshParser.#normalizePath(value)
|
|
510
|
+
)
|
|
511
|
+
.filter(Boolean)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Reads byte-like values from common model metadata shapes.
|
|
516
|
+
* @param {{ payloadText?: string, payloadBytes?: any, bytes?: any, data?: any, file?: any }} source Source metadata.
|
|
517
|
+
* @returns {Promise<Uint8Array | null>}
|
|
518
|
+
*/
|
|
519
|
+
static async #readAnyBytes(source) {
|
|
520
|
+
if (typeof source?.payloadText === 'string') {
|
|
521
|
+
return new TextEncoder().encode(source.payloadText)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const value of [
|
|
525
|
+
source?.payloadBytes,
|
|
526
|
+
source?.bytes,
|
|
527
|
+
source?.data,
|
|
528
|
+
source?.file,
|
|
529
|
+
source
|
|
530
|
+
]) {
|
|
531
|
+
const bytes =
|
|
532
|
+
await PcbAssemblyTextModelMeshParser.#bytesFromValue(value)
|
|
533
|
+
if (bytes) {
|
|
534
|
+
return bytes
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return null
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Converts one byte-like value to a Uint8Array.
|
|
543
|
+
* @param {any} value Candidate value.
|
|
544
|
+
* @returns {Promise<Uint8Array | null>}
|
|
545
|
+
*/
|
|
546
|
+
static async #bytesFromValue(value) {
|
|
547
|
+
if (!value) {
|
|
548
|
+
return null
|
|
549
|
+
}
|
|
550
|
+
if (value instanceof Uint8Array) {
|
|
551
|
+
return value
|
|
552
|
+
}
|
|
553
|
+
if (value instanceof ArrayBuffer) {
|
|
554
|
+
return new Uint8Array(value)
|
|
555
|
+
}
|
|
556
|
+
if (ArrayBuffer.isView(value) && !(value instanceof DataView)) {
|
|
557
|
+
return new Uint8Array(
|
|
558
|
+
value.buffer,
|
|
559
|
+
value.byteOffset,
|
|
560
|
+
value.byteLength
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
if (typeof value.arrayBuffer === 'function') {
|
|
564
|
+
return new Uint8Array(await value.arrayBuffer())
|
|
565
|
+
}
|
|
566
|
+
return null
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Resolves a mesh name.
|
|
571
|
+
* @param {{ name?: string }} model Model metadata.
|
|
572
|
+
* @param {string} fallback Fallback name.
|
|
573
|
+
* @returns {string}
|
|
574
|
+
*/
|
|
575
|
+
static #modelName(model, fallback) {
|
|
576
|
+
return String(model?.name || fallback || 'model')
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Normalizes a path-like value for matching.
|
|
581
|
+
* @param {unknown} value Path candidate.
|
|
582
|
+
* @returns {string}
|
|
583
|
+
*/
|
|
584
|
+
static #normalizePath(value) {
|
|
585
|
+
return String(value || '')
|
|
586
|
+
.split(/[?#]/u)[0]
|
|
587
|
+
.replace(/\\/gu, '/')
|
|
588
|
+
.replace(/^\/+/u, '')
|
|
589
|
+
.toLowerCase()
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Builds a safe identifier segment.
|
|
594
|
+
* @param {unknown} value Candidate name.
|
|
595
|
+
* @returns {string}
|
|
596
|
+
*/
|
|
597
|
+
static #safeName(value) {
|
|
598
|
+
return String(value || '')
|
|
599
|
+
.replace(/[^A-Za-z0-9_.-]+/gu, '_')
|
|
600
|
+
.replace(/^_+|_+$/gu, '')
|
|
601
|
+
}
|
|
602
|
+
}
|