pcb-scene3d-viewer 1.1.49 → 1.2.0

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 (63) hide show
  1. package/README.md +46 -21
  2. package/docs/api.md +109 -8
  3. package/docs/circuitjson.md +143 -29
  4. package/docs/model-format.md +50 -7
  5. package/docs/release-notes-v1.2.0.md +122 -0
  6. package/docs/testing.md +30 -1
  7. package/package.json +6 -3
  8. package/spec/library-scope.md +10 -2
  9. package/src/CircuitJsonCadModelAssetResolver.mjs +697 -83
  10. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +43 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +40 -18
  12. package/src/PcbAssemblyModelMeshLoader.mjs +101 -150
  13. package/src/PcbAssemblyPadMeshBuilder.mjs +22 -0
  14. package/src/PcbModelArchiveExporter.mjs +28 -112
  15. package/src/PcbModelArchiveSourceBundle.mjs +326 -0
  16. package/src/PcbScene3dAabbIndex.mjs +464 -0
  17. package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -1
  18. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +9 -5
  19. package/src/PcbScene3dBoardMaterialPalette.mjs +29 -0
  20. package/src/PcbScene3dBoardShapeFactory.mjs +11 -118
  21. package/src/PcbScene3dBoardSolderMaskFactory.mjs +65 -39
  22. package/src/PcbScene3dCircuitJsonAdapter.mjs +151 -48
  23. package/src/PcbScene3dCircuitJsonDrillDetail.mjs +31 -0
  24. package/src/PcbScene3dCircuitJsonGeometry.mjs +184 -33
  25. package/src/PcbScene3dCircuitJsonInput.mjs +132 -0
  26. package/src/PcbScene3dCircuitJsonModelAsset.mjs +40 -0
  27. package/src/PcbScene3dController.mjs +40 -34
  28. package/src/PcbScene3dCopperFactory.mjs +36 -22
  29. package/src/PcbScene3dCopperFillAreaClipper.mjs +133 -235
  30. package/src/PcbScene3dCopperFillCoverageContext.mjs +204 -0
  31. package/src/PcbScene3dCopperFillLoopSetResolver.mjs +192 -0
  32. package/src/PcbScene3dCopperFillMeshBuilder.mjs +77 -295
  33. package/src/PcbScene3dCopperTextFactory.mjs +12 -4
  34. package/src/PcbScene3dCutoutCircleDetector.mjs +34 -17
  35. package/src/PcbScene3dCutoutGeometryFilter.mjs +104 -269
  36. package/src/PcbScene3dCutoutGridIndex.mjs +184 -0
  37. package/src/PcbScene3dDeferredModelFinalizer.mjs +52 -0
  38. package/src/PcbScene3dDescriptorSafeRecord.mjs +38 -0
  39. package/src/PcbScene3dDrillCutoutFilter.mjs +149 -143
  40. package/src/PcbScene3dDrillPathFactory.mjs +86 -16
  41. package/src/PcbScene3dDrillVoidFactory.mjs +35 -10
  42. package/src/PcbScene3dExternalModelGroupLoader.mjs +472 -31
  43. package/src/PcbScene3dExternalModels.mjs +23 -24
  44. package/src/PcbScene3dFacetedModelGroupBuilder.mjs +217 -0
  45. package/src/PcbScene3dGeometryZCompressor.mjs +4 -2
  46. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +18 -4
  47. package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +75 -10
  48. package/src/PcbScene3dModelContent.mjs +236 -0
  49. package/src/PcbScene3dModelFetchPolicy.mjs +304 -0
  50. package/src/PcbScene3dModelIdentity.mjs +106 -0
  51. package/src/PcbScene3dPlatedDrillSpecResolver.mjs +141 -0
  52. package/src/PcbScene3dPreparedPolygon.mjs +709 -0
  53. package/src/PcbScene3dPreparedPolygonSet.mjs +70 -0
  54. package/src/PcbScene3dRuntime.mjs +45 -45
  55. package/src/PcbScene3dRuntimeBoardMeshes.mjs +85 -1
  56. package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +4 -2
  57. package/src/PcbScene3dShellRenderer.mjs +75 -6
  58. package/src/PcbScene3dSilkscreenCutoutContext.mjs +255 -0
  59. package/src/PcbScene3dSilkscreenFactory.mjs +87 -127
  60. package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +11 -5
  61. package/src/PcbScene3dStepLoader.mjs +11 -10
  62. package/src/PcbScene3dText.mjs +1 -1
  63. package/src/PcbScene3dTriangleVertexQueryBounds.mjs +302 -0
@@ -1,5 +1,13 @@
1
+ import { PcbAssemblyModelMeshLoader } from './PcbAssemblyModelMeshLoader.mjs'
1
2
  import { PcbScene3dBufferAttributeFactory } from './PcbScene3dBufferAttributeFactory.mjs'
3
+ import { PcbScene3dDescriptorSafeRecord } from './PcbScene3dDescriptorSafeRecord.mjs'
4
+ import { PcbScene3dFacetedModelGroupBuilder } from './PcbScene3dFacetedModelGroupBuilder.mjs'
2
5
  import { PcbScene3dModelBounds } from './PcbScene3dModelBounds.mjs'
6
+ import { PcbScene3dModelContent } from './PcbScene3dModelContent.mjs'
7
+ import { PcbScene3dModelIdentity } from './PcbScene3dModelIdentity.mjs'
8
+
9
+ const FACETED_FORMATS = new Set(['glb', 'gltf', 'obj', 'stl'])
10
+ const MM_TO_MIL = 1000 / 25.4
3
11
 
4
12
  /**
5
13
  * Loads raw external model geometry before placement-specific wrapping.
@@ -11,25 +19,43 @@ export class PcbScene3dExternalModelGroupLoader {
11
19
  * @param {any} model Resolved external model metadata.
12
20
  * @param {import('./PcbScene3dStepLoader.mjs').PcbScene3dStepLoader} stepLoader STEP model loader.
13
21
  * @param {string} versionKey Cache-busting version key for dynamic loader imports.
22
+ * @param {{ createVrmlLoader?: () => any | Promise<any>, createModelLoader?: (format: string) => any | Promise<any>, fetch?: (url: string, options: object) => Promise<any>, allowNetworkModelFetch?: boolean, authHeaders?: Record<string, string>, fetchTimeoutMs?: number, modelCache?: Map<string, Promise<Uint8Array>> }} [runtime] Optional loader runtime.
14
23
  * @returns {Promise<any>}
15
24
  */
16
- static async load(THREE, model, stepLoader, versionKey = '') {
17
- if (model.format === 'wrl') {
18
- if (!model.file) {
19
- throw new Error('Resolved WRL model file is unavailable.')
20
- }
21
-
25
+ static async load(THREE, model, stepLoader, versionKey = '', runtime = {}) {
26
+ const scopedRuntime = PcbScene3dModelContent.createFetchScope(runtime)
27
+ const format = String(model?.format || '').toLowerCase()
28
+ if (format === 'wrl' || format === 'vrml') {
22
29
  return PcbScene3dExternalModelGroupLoader.#loadVrmlModel(
23
- model.file,
24
- versionKey
30
+ model,
31
+ versionKey,
32
+ scopedRuntime
25
33
  )
26
34
  }
27
35
 
28
- if (model.format === 'step') {
36
+ if (format === 'step' || format === 'stp') {
29
37
  return PcbScene3dExternalModelGroupLoader.#loadStepModel(
30
38
  THREE,
31
39
  model,
32
- stepLoader
40
+ stepLoader,
41
+ scopedRuntime
42
+ )
43
+ }
44
+
45
+ if (FACETED_FORMATS.has(format)) {
46
+ return PcbScene3dExternalModelGroupLoader.#loadFacetedModel(
47
+ THREE,
48
+ model,
49
+ stepLoader,
50
+ scopedRuntime
51
+ )
52
+ }
53
+
54
+ if (format === '3mf') {
55
+ return PcbScene3dExternalModelGroupLoader.#loadThreeMfModel(
56
+ model,
57
+ versionKey,
58
+ scopedRuntime
33
59
  )
34
60
  }
35
61
 
@@ -37,33 +63,411 @@ export class PcbScene3dExternalModelGroupLoader {
37
63
  }
38
64
 
39
65
  /**
40
- * Loads one VRML model from a browser file.
41
- * @param {File | Blob} file Model file blob.
66
+ * Loads one VRML model from canonical bytes, text, or a browser file.
67
+ * @param {any} model Resolved WRL model metadata.
42
68
  * @param {string} versionKey Cache-busting version key for the loader import.
69
+ * @param {{ createVrmlLoader?: () => any | Promise<any>, createModelLoader?: (format: string) => any | Promise<any>, fetch?: (url: string, options: object) => Promise<any>, allowNetworkModelFetch?: boolean }} runtime Loader runtime.
43
70
  * @returns {Promise<any>}
44
71
  */
45
- static async #loadVrmlModel(file, versionKey) {
46
- const [{ VRMLLoader }] = await Promise.all([
47
- import(
48
- '/node_modules/three/examples/jsm/loaders/VRMLLoader.js' +
49
- (versionKey ? '?v=' + encodeURIComponent(versionKey) : '')
72
+ static async #loadVrmlModel(model, versionKey, runtime) {
73
+ const sourceText = await PcbScene3dExternalModelGroupLoader.#vrmlText(
74
+ model,
75
+ runtime
76
+ )
77
+ if (!sourceText) {
78
+ throw new Error('Resolved WRL model content is unavailable.')
79
+ }
80
+ const loader = await PcbScene3dExternalModelGroupLoader.#vrmlLoader(
81
+ versionKey,
82
+ runtime
83
+ )
84
+ const safeText =
85
+ await PcbScene3dExternalModelGroupLoader.#vrmlWithSafeTextures(
86
+ sourceText,
87
+ model,
88
+ runtime
50
89
  )
51
- ])
52
- const loader = new VRMLLoader()
53
- const objectUrl = URL.createObjectURL(file)
90
+ return loader.parse(safeText, '')
91
+ }
54
92
 
55
- try {
56
- return await new Promise((resolve, reject) => {
57
- loader.load(
58
- objectUrl,
59
- (loadedScene) => resolve(loadedScene),
60
- undefined,
61
- reject
93
+ /**
94
+ * Replaces VRML texture references with explicitly loaded data URIs.
95
+ * @param {string} text VRML source text.
96
+ * @param {object} model Resolved WRL model metadata.
97
+ * @param {object} runtime Loader runtime.
98
+ * @returns {Promise<string>}
99
+ */
100
+ static async #vrmlWithSafeTextures(text, model, runtime) {
101
+ const references =
102
+ PcbScene3dExternalModelGroupLoader.#vrmlTextureReferences(text)
103
+ if (!references.length) return text
104
+
105
+ const resources =
106
+ PcbScene3dExternalModelGroupLoader.#vrmlResourceIndex(model)
107
+ const resolved = new Map()
108
+ for (const reference of references) {
109
+ resolved.set(
110
+ reference,
111
+ await PcbScene3dExternalModelGroupLoader.#vrmlTextureDataUri(
112
+ reference,
113
+ model,
114
+ resources,
115
+ runtime
62
116
  )
117
+ )
118
+ }
119
+
120
+ return String(text).replace(
121
+ /(['"])([^'"]+)\1/gu,
122
+ (match, quote, reference) =>
123
+ resolved.has(reference)
124
+ ? quote + resolved.get(reference) + quote
125
+ : match
126
+ )
127
+ }
128
+
129
+ /**
130
+ * Lists unique URLs declared by VRML ImageTexture nodes.
131
+ * @param {string} text VRML source text.
132
+ * @returns {string[]}
133
+ */
134
+ static #vrmlTextureReferences(text) {
135
+ const references = new Set()
136
+ for (const block of String(text || '').matchAll(
137
+ /ImageTexture\s*\{[\s\S]*?\}/giu
138
+ )) {
139
+ for (const quoted of String(block[0] || '').matchAll(
140
+ /(['"])([^'"]+)\1/gu
141
+ )) {
142
+ const reference = String(quoted[2] || '').trim()
143
+ if (reference) references.add(reference)
144
+ }
145
+ }
146
+ return [...references]
147
+ }
148
+
149
+ /**
150
+ * Resolves one VRML texture through local resources or explicit fetch policy.
151
+ * @param {string} reference Texture URL from the VRML source.
152
+ * @param {object} model Resolved WRL model metadata.
153
+ * @param {Map<string, object>} resources Local resource index.
154
+ * @param {object} runtime Loader runtime.
155
+ * @returns {Promise<string>}
156
+ */
157
+ static async #vrmlTextureDataUri(reference, model, resources, runtime) {
158
+ if (reference.startsWith('data:')) return reference
159
+ const modelPath = PcbScene3dModelIdentity.projectPath(model)
160
+ const baseDirectory = modelPath.includes('/')
161
+ ? modelPath.slice(0, modelPath.lastIndexOf('/') + 1)
162
+ : ''
163
+ const projectPath = PcbScene3dModelContent.safeProjectPath(
164
+ reference,
165
+ baseDirectory
166
+ )
167
+ const resource =
168
+ resources.get(
169
+ PcbScene3dExternalModelGroupLoader.#vrmlResourceKey(projectPath)
170
+ ) ||
171
+ resources.get(
172
+ PcbScene3dExternalModelGroupLoader.#vrmlResourceKey(reference)
173
+ )
174
+ if (resource) {
175
+ const bytes = await PcbScene3dModelContent.bytes(
176
+ resource,
177
+ runtime,
178
+ 'VRML texture'
179
+ )
180
+ return PcbScene3dExternalModelGroupLoader.#dataUri(bytes, reference)
181
+ }
182
+
183
+ if (
184
+ typeof runtime?.fetch !== 'function' &&
185
+ runtime?.allowNetworkModelFetch !== true
186
+ ) {
187
+ return 'data:,'
188
+ }
189
+ const base = String(
190
+ model?.resolvedUrl || model?.sourceUrl || modelPath || ''
191
+ ).trim()
192
+ const resolvedUrl = PcbScene3dModelContent.resolveRelativeUrl(
193
+ reference,
194
+ base
195
+ )
196
+ if (!resolvedUrl) return 'data:,'
197
+ const bytes = await PcbScene3dModelContent.bytes(
198
+ {
199
+ format: 'texture',
200
+ name: reference,
201
+ resolvedUrl,
202
+ mainModelUrl: base,
203
+ ...(projectPath
204
+ ? { source: { projectRelativePath: projectPath } }
205
+ : {})
206
+ },
207
+ runtime,
208
+ 'VRML texture'
209
+ )
210
+ return PcbScene3dExternalModelGroupLoader.#dataUri(bytes, reference)
211
+ }
212
+
213
+ /**
214
+ * Builds an exact alias index for local WRL companion resources.
215
+ * @param {object} model Resolved WRL model metadata.
216
+ * @returns {Map<string, object>}
217
+ */
218
+ static #vrmlResourceIndex(model) {
219
+ const index = new Map()
220
+ const resources = PcbScene3dExternalModelGroupLoader.#resourceArray(
221
+ PcbScene3dExternalModelGroupLoader.#ownData(model, 'resources')
222
+ )
223
+ resources.forEach((resource) => {
224
+ const source = PcbScene3dExternalModelGroupLoader.#ownData(
225
+ resource,
226
+ 'source'
227
+ )
228
+ const aliases = [
229
+ PcbScene3dExternalModelGroupLoader.#ownData(resource, 'uri'),
230
+ PcbScene3dExternalModelGroupLoader.#ownData(resource, 'name'),
231
+ PcbScene3dExternalModelGroupLoader.#ownData(
232
+ resource,
233
+ 'relativePath'
234
+ ),
235
+ PcbScene3dExternalModelGroupLoader.#ownData(
236
+ source,
237
+ 'projectRelativePath'
238
+ ),
239
+ PcbScene3dExternalModelGroupLoader.#ownData(
240
+ source,
241
+ 'project_relative_path'
242
+ )
243
+ ]
244
+ aliases.forEach((alias) => {
245
+ const key =
246
+ PcbScene3dExternalModelGroupLoader.#vrmlResourceKey(alias)
247
+ if (key && !index.has(key)) index.set(key, resource)
63
248
  })
64
- } finally {
65
- URL.revokeObjectURL(objectUrl)
249
+ })
250
+ return index
251
+ }
252
+
253
+ /**
254
+ * Converts local resources to a descriptor-safe array.
255
+ * @param {unknown} value Resource collection.
256
+ * @returns {object[]}
257
+ */
258
+ static #resourceArray(value) {
259
+ try {
260
+ if (Array.isArray(value)) {
261
+ if (Object.getPrototypeOf(value) !== Array.prototype) return []
262
+ const descriptors = Object.getOwnPropertyDescriptors(value)
263
+ const length = descriptors.length?.value
264
+ if (!Number.isSafeInteger(length) || length < 0) return []
265
+ const resources = []
266
+ for (let index = 0; index < length; index += 1) {
267
+ const descriptor = descriptors[String(index)]
268
+ if (!descriptor || !Object.hasOwn(descriptor, 'value')) {
269
+ return []
270
+ }
271
+ resources.push(descriptor.value)
272
+ }
273
+ return resources
274
+ }
275
+ if (!value || typeof value !== 'object') return []
276
+ return Object.entries(Object.getOwnPropertyDescriptors(value))
277
+ .filter(([, descriptor]) => Object.hasOwn(descriptor, 'value'))
278
+ .map(([name, descriptor]) => ({
279
+ name,
280
+ ...PcbScene3dDescriptorSafeRecord.copy(descriptor.value)
281
+ }))
282
+ } catch {
283
+ return []
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Encodes texture bytes as a browser-safe data URI.
289
+ * @param {Uint8Array} bytes Texture bytes.
290
+ * @param {string} reference Texture file reference.
291
+ * @returns {string}
292
+ */
293
+ static #dataUri(bytes, reference) {
294
+ const alphabet =
295
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
296
+ let base64 = ''
297
+ for (let index = 0; index < bytes.length; index += 3) {
298
+ const first = bytes[index]
299
+ const second = bytes[index + 1]
300
+ const third = bytes[index + 2]
301
+ const packed =
302
+ (first << 16) |
303
+ ((second === undefined ? 0 : second) << 8) |
304
+ (third === undefined ? 0 : third)
305
+ base64 += alphabet[(packed >>> 18) & 63]
306
+ base64 += alphabet[(packed >>> 12) & 63]
307
+ base64 += second === undefined ? '=' : alphabet[(packed >>> 6) & 63]
308
+ base64 += third === undefined ? '=' : alphabet[packed & 63]
309
+ }
310
+ return (
311
+ 'data:' +
312
+ PcbScene3dExternalModelGroupLoader.#textureMediaType(reference) +
313
+ ';base64,' +
314
+ base64
315
+ )
316
+ }
317
+
318
+ /**
319
+ * Resolves a common texture media type from its filename.
320
+ * @param {string} reference Texture file reference.
321
+ * @returns {string}
322
+ */
323
+ static #textureMediaType(reference) {
324
+ const extension = String(reference || '')
325
+ .split(/[?#]/u)[0]
326
+ .split('.')
327
+ .pop()
328
+ .toLowerCase()
329
+ return (
330
+ {
331
+ bmp: 'image/bmp',
332
+ gif: 'image/gif',
333
+ jpeg: 'image/jpeg',
334
+ jpg: 'image/jpeg',
335
+ png: 'image/png',
336
+ webp: 'image/webp'
337
+ }[extension] || 'application/octet-stream'
338
+ )
339
+ }
340
+
341
+ /**
342
+ * Normalizes a local resource alias.
343
+ * @param {unknown} value Resource path candidate.
344
+ * @returns {string}
345
+ */
346
+ static #vrmlResourceKey(value) {
347
+ return String(value || '')
348
+ .trim()
349
+ .split(/[?#]/u)[0]
350
+ .replaceAll('\\', '/')
351
+ .replace(/^\.\//u, '')
352
+ .toLowerCase()
353
+ }
354
+
355
+ /**
356
+ * Reads one own data property without invoking accessors.
357
+ * @param {unknown} value Record candidate.
358
+ * @param {PropertyKey} key Property key.
359
+ * @returns {unknown}
360
+ */
361
+ static #ownData(value, key) {
362
+ if (!value || typeof value !== 'object') return undefined
363
+ try {
364
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
365
+ return descriptor && Object.hasOwn(descriptor, 'value')
366
+ ? descriptor.value
367
+ : undefined
368
+ } catch {
369
+ return undefined
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Creates the production or injected Three.js VRML loader.
375
+ * @param {string} versionKey Cache-busting version key.
376
+ * @param {{ createVrmlLoader?: () => any | Promise<any>, createModelLoader?: (format: string) => any | Promise<any> }} runtime Loader runtime.
377
+ * @returns {Promise<any>}
378
+ */
379
+ static async #vrmlLoader(versionKey, runtime) {
380
+ if (typeof runtime?.createModelLoader === 'function') {
381
+ return runtime.createModelLoader('wrl')
382
+ }
383
+ if (typeof runtime?.createVrmlLoader === 'function') {
384
+ return runtime.createVrmlLoader()
66
385
  }
386
+ const { VRMLLoader } = await import(
387
+ '/node_modules/three/examples/jsm/loaders/VRMLLoader.js' +
388
+ (versionKey ? '?v=' + encodeURIComponent(versionKey) : '')
389
+ )
390
+ return new VRMLLoader()
391
+ }
392
+
393
+ /**
394
+ * Reads UTF-8 VRML text from every supported external-model source shape.
395
+ * @param {any} model Resolved WRL model metadata.
396
+ * @param {object} runtime Loader runtime.
397
+ * @returns {Promise<string>}
398
+ */
399
+ static async #vrmlText(model, runtime) {
400
+ return PcbScene3dModelContent.text(model, runtime, 'WRL')
401
+ }
402
+
403
+ /**
404
+ * Loads STL, OBJ, GLTF, or GLB through the shared faceted model pipeline.
405
+ * @param {any} THREE Three.js namespace.
406
+ * @param {any} model Resolved external model metadata.
407
+ * @param {import('./PcbScene3dStepLoader.mjs').PcbScene3dStepLoader} stepLoader STEP model loader.
408
+ * @param {object} runtime Loader runtime.
409
+ * @returns {Promise<any>}
410
+ */
411
+ static async #loadFacetedModel(THREE, model, stepLoader, runtime) {
412
+ const loader = new PcbAssemblyModelMeshLoader({
413
+ stepLoader,
414
+ fetch: runtime?.fetch,
415
+ allowNetworkModelFetch: runtime?.allowNetworkModelFetch,
416
+ authHeaders: runtime?.authHeaders,
417
+ fetchTimeoutMs: runtime?.fetchTimeoutMs,
418
+ modelCache: runtime?.modelCache
419
+ })
420
+ const meshes = await loader.loadPlacement({ externalModel: model })
421
+ return PcbScene3dFacetedModelGroupBuilder.build(THREE, meshes)
422
+ }
423
+
424
+ /**
425
+ * Loads a 3MF model with the Three.js loader and converts millimeters to mils.
426
+ * @param {any} model Resolved 3MF model metadata.
427
+ * @param {string} versionKey Cache-busting loader version.
428
+ * @param {object} runtime Loader runtime.
429
+ * @returns {Promise<any>}
430
+ */
431
+ static async #loadThreeMfModel(model, versionKey, runtime) {
432
+ const bytes = await PcbScene3dModelContent.bytes(model, runtime, '3MF')
433
+ const loader = await PcbScene3dExternalModelGroupLoader.#threeMfLoader(
434
+ versionKey,
435
+ runtime
436
+ )
437
+ const group = loader.parse(
438
+ PcbScene3dExternalModelGroupLoader.#arrayBuffer(bytes)
439
+ )
440
+ group?.scale?.setScalar?.(MM_TO_MIL)
441
+ return group
442
+ }
443
+
444
+ /**
445
+ * Creates the production or injected Three.js 3MF loader.
446
+ * @param {string} versionKey Cache-busting version key.
447
+ * @param {object} runtime Loader runtime.
448
+ * @returns {Promise<any>}
449
+ */
450
+ static async #threeMfLoader(versionKey, runtime) {
451
+ if (typeof runtime?.createModelLoader === 'function') {
452
+ return runtime.createModelLoader('3mf')
453
+ }
454
+ const { ThreeMFLoader } = await import(
455
+ '/node_modules/three/examples/jsm/loaders/3MFLoader.js' +
456
+ (versionKey ? '?v=' + encodeURIComponent(versionKey) : '')
457
+ )
458
+ return new ThreeMFLoader()
459
+ }
460
+
461
+ /**
462
+ * Returns an exact ArrayBuffer view for a byte slice.
463
+ * @param {Uint8Array} bytes Source bytes.
464
+ * @returns {ArrayBuffer}
465
+ */
466
+ static #arrayBuffer(bytes) {
467
+ return bytes.buffer.slice(
468
+ bytes.byteOffset,
469
+ bytes.byteOffset + bytes.byteLength
470
+ )
67
471
  }
68
472
 
69
473
  /**
@@ -71,12 +475,19 @@ export class PcbScene3dExternalModelGroupLoader {
71
475
  * @param {any} THREE Three.js namespace.
72
476
  * @param {any} model Resolved STEP model metadata.
73
477
  * @param {import('./PcbScene3dStepLoader.mjs').PcbScene3dStepLoader} stepLoader STEP model loader.
478
+ * @param {object} runtime Loader runtime.
74
479
  * @returns {Promise<any>}
75
480
  */
76
- static async #loadStepModel(THREE, model, stepLoader) {
481
+ static async #loadStepModel(THREE, model, stepLoader, runtime) {
482
+ const resolvedModel =
483
+ await PcbScene3dExternalModelGroupLoader.#modelWithUrlBytes(
484
+ model,
485
+ runtime,
486
+ 'STEP'
487
+ )
77
488
  const loadedModel = Array.isArray(model?.preparedMeshPayloads)
78
489
  ? { meshPayloads: model.preparedMeshPayloads }
79
- : await stepLoader.loadModel(model)
490
+ : await stepLoader.loadModel(resolvedModel)
80
491
  const group = new THREE.Group()
81
492
  const sourceBounds = PcbScene3dModelBounds.measureSourceBoundsMil(
82
493
  loadedModel.meshPayloads
@@ -134,6 +545,36 @@ export class PcbScene3dExternalModelGroupLoader {
134
545
  return group
135
546
  }
136
547
 
548
+ /**
549
+ * Attaches fetched URL bytes only when a model has no local content.
550
+ * @param {any} model External model metadata.
551
+ * @param {object} runtime Loader runtime.
552
+ * @param {string} format Human-readable model format.
553
+ * @returns {Promise<any>}
554
+ */
555
+ static async #modelWithUrlBytes(model, runtime, format) {
556
+ if (
557
+ Array.isArray(model?.preparedMeshPayloads) ||
558
+ PcbScene3dModelContent.hasLocal(model)
559
+ ) {
560
+ return model
561
+ }
562
+ const url = String(model?.resolvedUrl || model?.sourceUrl || '').trim()
563
+ const canFetch =
564
+ typeof runtime?.fetch === 'function' ||
565
+ (runtime?.allowNetworkModelFetch === true &&
566
+ typeof globalThis.fetch === 'function')
567
+ if (!url || !canFetch) return model
568
+ return {
569
+ ...model,
570
+ payloadBytes: await PcbScene3dModelContent.bytes(
571
+ model,
572
+ runtime,
573
+ format
574
+ )
575
+ }
576
+ }
577
+
137
578
  /**
138
579
  * Builds the material set for one STEP mesh and assigns face-color groups
139
580
  * when the importer exposes them.
@@ -8,6 +8,8 @@ import { PcbScene3dExternalModelLoadOrder } from './PcbScene3dExternalModelLoadO
8
8
  import { PcbScene3dExternalModelSourceOriginPolicy } from './PcbScene3dExternalModelSourceOriginPolicy.mjs'
9
9
  import { PcbScene3dExternalPlacementDefaults } from './PcbScene3dExternalPlacementDefaults.mjs'
10
10
  import { PcbScene3dModelBounds } from './PcbScene3dModelBounds.mjs'
11
+ import { PcbScene3dModelIdentity } from './PcbScene3dModelIdentity.mjs'
12
+ import { PcbScene3dModelContent } from './PcbScene3dModelContent.mjs'
11
13
  import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
12
14
  import { PcbScene3dStepLoader } from './PcbScene3dStepLoader.mjs'
13
15
  import { PcbScene3dViewCompensation } from './PcbScene3dViewCompensation.mjs'
@@ -18,10 +20,13 @@ import { PcbScene3dViewCompensation } from './PcbScene3dViewCompensation.mjs'
18
20
  export class PcbScene3dExternalModels {
19
21
  /**
20
22
  * Loads all available external models into the supplied group.
21
- * @param {{ three: any, sceneDescription: any, externalModelsGroup: any, modelViewScale?: { x?: number, y?: number, z?: number }, isDisposed?: () => boolean, onPlacementGroup?: (placement: any, placementGroup: any) => void, stepLoader?: PcbScene3dStepLoader }} options
23
+ * @param {{ three: any, sceneDescription: any, externalModelsGroup: any, modelViewScale?: { x?: number, y?: number, z?: number }, isDisposed?: () => boolean, onPlacementGroup?: (placement: any, placementGroup: any) => void, stepLoader?: PcbScene3dStepLoader, modelLoaderOptions?: object }} options
22
24
  * @returns {Promise<string[]>}
23
25
  */
24
26
  static async loadIntoScene(options) {
27
+ const modelLoaderOptions = PcbScene3dModelContent.createFetchScope(
28
+ options?.modelLoaderOptions
29
+ )
25
30
  const placements = PcbScene3dExternalModelLoadOrder.sort(
26
31
  PcbScene3dExternalModels.#resolvePlacements(
27
32
  options?.sceneDescription
@@ -52,7 +57,8 @@ export class PcbScene3dExternalModels {
52
57
  stepLoader,
53
58
  cachedModelGroups,
54
59
  options?.modelViewScale,
55
- options?.sceneDescription
60
+ options?.sceneDescription,
61
+ modelLoaderOptions
56
62
  )
57
63
  if (!loadedGroup || options?.isDisposed?.()) {
58
64
  continue
@@ -183,6 +189,7 @@ export class PcbScene3dExternalModels {
183
189
  * @param {Map<string, any>} cachedModelGroups
184
190
  * @param {{ x?: number, y?: number, z?: number } | null | undefined} modelViewScale Active scene view scale.
185
191
  * @param {object | null | undefined} sceneDescription Scene description.
192
+ * @param {object | null | undefined} modelLoaderOptions Model loader options.
186
193
  * @returns {Promise<any>}
187
194
  */
188
195
  static async #loadPlacementGroup(
@@ -191,7 +198,8 @@ export class PcbScene3dExternalModels {
191
198
  stepLoader,
192
199
  cachedModelGroups,
193
200
  modelViewScale,
194
- sceneDescription
201
+ sceneDescription,
202
+ modelLoaderOptions
195
203
  ) {
196
204
  const model = placement?.externalModel
197
205
  if (!model) {
@@ -203,7 +211,8 @@ export class PcbScene3dExternalModels {
203
211
  THREE,
204
212
  model,
205
213
  stepLoader,
206
- cachedModelGroups
214
+ cachedModelGroups,
215
+ modelLoaderOptions
207
216
  )
208
217
 
209
218
  return PcbScene3dExternalModels.#buildPlacementWrapper(
@@ -222,13 +231,15 @@ export class PcbScene3dExternalModels {
222
231
  * @param {any} model
223
232
  * @param {PcbScene3dStepLoader} stepLoader
224
233
  * @param {Map<string, any>} cachedModelGroups
234
+ * @param {object | null | undefined} modelLoaderOptions Model loader options.
225
235
  * @returns {Promise<any>}
226
236
  */
227
237
  static async #loadCachedModelGroup(
228
238
  THREE,
229
239
  model,
230
240
  stepLoader,
231
- cachedModelGroups
241
+ cachedModelGroups,
242
+ modelLoaderOptions
232
243
  ) {
233
244
  const identity = PcbScene3dExternalModels.#resolveModelIdentity(model)
234
245
  if (cachedModelGroups.has(identity)) {
@@ -238,7 +249,8 @@ export class PcbScene3dExternalModels {
238
249
  const modelGroup = await PcbScene3dExternalModels.#loadModelGroup(
239
250
  THREE,
240
251
  model,
241
- stepLoader
252
+ stepLoader,
253
+ modelLoaderOptions
242
254
  )
243
255
  cachedModelGroups.set(identity, modelGroup)
244
256
 
@@ -250,14 +262,16 @@ export class PcbScene3dExternalModels {
250
262
  * @param {any} THREE
251
263
  * @param {any} model
252
264
  * @param {PcbScene3dStepLoader} stepLoader
265
+ * @param {object | null | undefined} modelLoaderOptions Model loader options.
253
266
  * @returns {Promise<any>}
254
267
  */
255
- static async #loadModelGroup(THREE, model, stepLoader) {
268
+ static async #loadModelGroup(THREE, model, stepLoader, modelLoaderOptions) {
256
269
  return PcbScene3dExternalModelGroupLoader.load(
257
270
  THREE,
258
271
  model,
259
272
  stepLoader,
260
- new URL(import.meta.url).searchParams.get('v') || ''
273
+ new URL(import.meta.url).searchParams.get('v') || '',
274
+ modelLoaderOptions || {}
261
275
  )
262
276
  }
263
277
 
@@ -719,22 +733,7 @@ export class PcbScene3dExternalModels {
719
733
  * @returns {string}
720
734
  */
721
735
  static #resolveModelIdentity(model) {
722
- if (String(model?.origin || '') === 'embedded') {
723
- return [
724
- 'embedded',
725
- String(model?.sourceStream || ''),
726
- String(model?.name || ''),
727
- String(model?.checksum || ''),
728
- String(model?.format || '')
729
- ].join('::')
730
- }
731
-
732
- return [
733
- 'session',
734
- String(model?.relativePath || ''),
735
- String(model?.name || ''),
736
- String(model?.format || '')
737
- ].join('::')
736
+ return PcbScene3dModelIdentity.resolve(model)
738
737
  }
739
738
 
740
739
  /**