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,3 +1,13 @@
1
+ import { CircuitJsonDocumentContext } from 'circuitjson-toolkit'
2
+ import { ToolkitAsset } from 'circuitjson-toolkit/parser'
3
+ import { PcbScene3dCircuitJsonInput } from './PcbScene3dCircuitJsonInput.mjs'
4
+ import { PcbScene3dDescriptorSafeRecord } from './PcbScene3dDescriptorSafeRecord.mjs'
5
+ import { PcbScene3dCircuitJsonModelAsset } from './PcbScene3dCircuitJsonModelAsset.mjs'
6
+ import { PcbScene3dModelContent } from './PcbScene3dModelContent.mjs'
7
+ import { PcbScene3dModelIdentity } from './PcbScene3dModelIdentity.mjs'
8
+
9
+ const AMBIGUOUS_ASSET = Symbol('ambiguous-model-asset')
10
+
1
11
  /**
2
12
  * Bridges CAD component model asset metadata to scene model resolution.
3
13
  */
@@ -10,6 +20,9 @@ export class CircuitJsonCadModelAssetResolver {
10
20
  static withModelAssetUrls(documentModel) {
11
21
  const elements =
12
22
  CircuitJsonCadModelAssetResolver.#elements(documentModel)
23
+ if (!PcbScene3dCircuitJsonInput.isModel(elements)) {
24
+ return documentModel
25
+ }
13
26
  if (
14
27
  !elements.some((element) =>
15
28
  CircuitJsonCadModelAssetResolver.#modelAssetUrlField(element)
@@ -24,44 +37,208 @@ export class CircuitJsonCadModelAssetResolver {
24
37
 
25
38
  if (Array.isArray(documentModel)) {
26
39
  Object.assign(nextElements, {
27
- sourceFormat: documentModel.sourceFormat,
28
- kind: documentModel.kind,
29
- fileName: documentModel.fileName,
30
- bom: documentModel.bom
40
+ sourceFormat: CircuitJsonCadModelAssetResolver.#ownData(
41
+ documentModel,
42
+ 'sourceFormat'
43
+ ),
44
+ kind: CircuitJsonCadModelAssetResolver.#ownData(
45
+ documentModel,
46
+ 'kind'
47
+ ),
48
+ fileName: CircuitJsonCadModelAssetResolver.#ownData(
49
+ documentModel,
50
+ 'fileName'
51
+ ),
52
+ bom: CircuitJsonCadModelAssetResolver.#ownData(
53
+ documentModel,
54
+ 'bom'
55
+ )
31
56
  })
32
57
  return nextElements
33
58
  }
34
59
 
35
- if (Array.isArray(documentModel?.elements)) {
36
- return { ...documentModel, elements: nextElements }
60
+ if (
61
+ CircuitJsonCadModelAssetResolver.#isContext(documentModel) &&
62
+ Array.isArray(documentModel.document?.model)
63
+ ) {
64
+ const document = documentModel.document
65
+ return CircuitJsonDocumentContext.prepare(
66
+ {
67
+ ...PcbScene3dDescriptorSafeRecord.copy(document),
68
+ model: nextElements
69
+ },
70
+ { indexes: ['elements'] }
71
+ )
37
72
  }
38
- if (Array.isArray(documentModel?.circuitJson)) {
39
- return { ...documentModel, circuitJson: nextElements }
73
+ const wrappedDocument = CircuitJsonCadModelAssetResolver.#ownData(
74
+ documentModel,
75
+ 'document'
76
+ )
77
+ if (
78
+ Array.isArray(
79
+ CircuitJsonCadModelAssetResolver.#ownData(
80
+ wrappedDocument,
81
+ 'model'
82
+ )
83
+ )
84
+ ) {
85
+ return {
86
+ ...PcbScene3dDescriptorSafeRecord.copy(wrappedDocument),
87
+ model: nextElements
88
+ }
89
+ }
90
+ const model = CircuitJsonCadModelAssetResolver.#ownData(
91
+ documentModel,
92
+ 'model'
93
+ )
94
+ if (Array.isArray(model)) {
95
+ return {
96
+ ...PcbScene3dDescriptorSafeRecord.copy(documentModel),
97
+ model: nextElements
98
+ }
99
+ }
100
+ const wrappedElements = CircuitJsonCadModelAssetResolver.#ownData(
101
+ documentModel,
102
+ 'elements'
103
+ )
104
+ if (Array.isArray(wrappedElements)) {
105
+ return {
106
+ ...PcbScene3dDescriptorSafeRecord.copy(documentModel),
107
+ elements: nextElements
108
+ }
109
+ }
110
+ const circuitJson = CircuitJsonCadModelAssetResolver.#ownData(
111
+ documentModel,
112
+ 'circuitJson'
113
+ )
114
+ if (Array.isArray(circuitJson)) {
115
+ return {
116
+ ...PcbScene3dDescriptorSafeRecord.copy(documentModel),
117
+ circuitJson: nextElements
118
+ }
40
119
  }
41
120
  return documentModel
42
121
  }
43
122
 
123
+ /**
124
+ * Returns whether an input is a prepared CircuitJSON context.
125
+ * @param {unknown} value Candidate input.
126
+ * @returns {boolean}
127
+ */
128
+ static #isContext(value) {
129
+ try {
130
+ return value instanceof CircuitJsonDocumentContext
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
44
136
  /**
45
137
  * Adds a session-asset-aware model URL resolver.
46
138
  * @param {object} options Scene preparation options.
139
+ * @param {object[]} [documentAssets] Canonical document assets.
47
140
  * @returns {object}
48
141
  */
49
- static withSessionAssetResolver(options = {}) {
50
- const sessionAssets = Array.isArray(options.sessionAssets)
51
- ? options.sessionAssets
52
- : []
53
- const callerResolver =
54
- typeof options.modelUrlResolver === 'function'
55
- ? options.modelUrlResolver
56
- : null
142
+ static withSessionAssetResolver(options = {}, documentAssets = []) {
143
+ const safeOptions = PcbScene3dDescriptorSafeRecord.copy(options)
144
+ const sessionAssets = CircuitJsonCadModelAssetResolver.#denseArray(
145
+ CircuitJsonCadModelAssetResolver.#ownData(
146
+ safeOptions,
147
+ 'sessionAssets'
148
+ )
149
+ )
150
+ const canonicalAssets =
151
+ CircuitJsonCadModelAssetResolver.#denseArray(documentAssets)
152
+ const sessionAssetIndex =
153
+ CircuitJsonCadModelAssetResolver.#assetIndex(sessionAssets)
154
+ const documentAssetIndex = CircuitJsonCadModelAssetResolver.#assetIndex(
155
+ canonicalAssets,
156
+ true
157
+ )
158
+ return CircuitJsonCadModelAssetResolver.#withAssetIndexes(
159
+ safeOptions,
160
+ sessionAssets,
161
+ sessionAssetIndex,
162
+ documentAssetIndex
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Adds a model URL resolver backed by a context-cached document asset index.
168
+ * @param {object} options Scene preparation options.
169
+ * @param {CircuitJsonDocumentContext} context Prepared document context.
170
+ * @returns {object}
171
+ */
172
+ static withContextAssetResolver(options = {}, context) {
173
+ if (!CircuitJsonCadModelAssetResolver.#isContext(context)) {
174
+ return CircuitJsonCadModelAssetResolver.withSessionAssetResolver(
175
+ options
176
+ )
177
+ }
178
+
179
+ const safeOptions = PcbScene3dDescriptorSafeRecord.copy(options)
180
+ const sessionAssets = CircuitJsonCadModelAssetResolver.#denseArray(
181
+ CircuitJsonCadModelAssetResolver.#ownData(
182
+ safeOptions,
183
+ 'sessionAssets'
184
+ )
185
+ )
186
+ const canonicalAssets = CircuitJsonCadModelAssetResolver.#denseArray(
187
+ context.assets
188
+ )
189
+ const sessionAssetIndex =
190
+ CircuitJsonCadModelAssetResolver.#assetIndex(sessionAssets)
191
+ const documentAssetIndex = canonicalAssets.length
192
+ ? context.getOrCreateDerived(
193
+ 'pcb-scene3d-viewer',
194
+ 'model-assets-v1',
195
+ () =>
196
+ CircuitJsonCadModelAssetResolver.#assetIndex(
197
+ canonicalAssets,
198
+ true
199
+ )
200
+ )
201
+ : CircuitJsonCadModelAssetResolver.#assetIndex([])
202
+
203
+ return CircuitJsonCadModelAssetResolver.#withAssetIndexes(
204
+ safeOptions,
205
+ sessionAssets,
206
+ sessionAssetIndex,
207
+ documentAssetIndex
208
+ )
209
+ }
210
+
211
+ /**
212
+ * Creates safe resolver options from prepared asset indexes.
213
+ * @param {Record<string, unknown>} safeOptions Descriptor-safe options.
214
+ * @param {unknown[]} sessionAssets Captured session asset rows.
215
+ * @param {Map<string, object>} sessionAssetIndex Session asset index.
216
+ * @param {Map<string, object>} documentAssetIndex Document asset index.
217
+ * @returns {object}
218
+ */
219
+ static #withAssetIndexes(
220
+ safeOptions,
221
+ sessionAssets,
222
+ sessionAssetIndex,
223
+ documentAssetIndex
224
+ ) {
225
+ const resolver = CircuitJsonCadModelAssetResolver.#ownData(
226
+ safeOptions,
227
+ 'modelUrlResolver'
228
+ )
229
+ const callerResolver = typeof resolver === 'function' ? resolver : null
57
230
 
58
231
  return {
59
- ...options,
232
+ ...safeOptions,
233
+ ...(Object.hasOwn(safeOptions, 'sessionAssets')
234
+ ? { sessionAssets }
235
+ : {}),
60
236
  modelUrlResolver: (url, context) =>
61
237
  CircuitJsonCadModelAssetResolver.#resolveModelUrl(
62
238
  url,
63
239
  context,
64
- sessionAssets,
240
+ sessionAssetIndex,
241
+ documentAssetIndex,
65
242
  callerResolver
66
243
  )
67
244
  }
@@ -76,12 +253,22 @@ export class CircuitJsonCadModelAssetResolver {
76
253
  const field =
77
254
  CircuitJsonCadModelAssetResolver.#modelAssetUrlField(element)
78
255
  if (!field) return element
79
- const asset = element.model_asset || {}
256
+ const asset =
257
+ CircuitJsonCadModelAssetResolver.#ownData(element, 'model_asset') ||
258
+ {}
80
259
  const sourceUrl = String(
81
- asset.project_relative_path || asset.url || ''
260
+ CircuitJsonCadModelAssetResolver.#ownData(
261
+ asset,
262
+ 'project_relative_path'
263
+ ) ||
264
+ CircuitJsonCadModelAssetResolver.#ownData(asset, 'url') ||
265
+ ''
82
266
  ).trim()
83
267
  if (!sourceUrl) return element
84
- return { ...element, [field]: sourceUrl }
268
+ return {
269
+ ...PcbScene3dDescriptorSafeRecord.copy(element),
270
+ [field]: sourceUrl
271
+ }
85
272
  }
86
273
 
87
274
  /**
@@ -90,11 +277,19 @@ export class CircuitJsonCadModelAssetResolver {
90
277
  * @returns {string}
91
278
  */
92
279
  static #modelAssetUrlField(element) {
93
- if (element?.type !== 'cad_component') return ''
94
- const asset = element.model_asset
280
+ if (
281
+ CircuitJsonCadModelAssetResolver.#ownData(element, 'type') !==
282
+ 'cad_component'
283
+ ) {
284
+ return ''
285
+ }
286
+ const asset = CircuitJsonCadModelAssetResolver.#ownData(
287
+ element,
288
+ 'model_asset'
289
+ )
95
290
  if (!asset || typeof asset !== 'object') return ''
96
291
  if (CircuitJsonCadModelAssetResolver.#hasModelUrl(element)) return ''
97
- const format = CircuitJsonCadModelAssetResolver.#format(asset)
292
+ const format = PcbScene3dCircuitJsonModelAsset.reference(asset)?.format
98
293
  return (
99
294
  {
100
295
  '3mf': 'model_3mf_url',
@@ -112,42 +307,445 @@ export class CircuitJsonCadModelAssetResolver {
112
307
  * Resolves a URL to a session asset or caller-provided value.
113
308
  * @param {string} url Model URL.
114
309
  * @param {object} context Resolver context.
115
- * @param {object[]} sessionAssets Session asset rows.
310
+ * @param {Map<string, object>} sessionAssetIndex Session asset index.
311
+ * @param {Map<string, object>} documentAssetIndex Canonical document asset index.
116
312
  * @param {((url: string, context: object) => any) | null} callerResolver Caller resolver.
117
313
  * @returns {object | string | null}
118
314
  */
119
- static #resolveModelUrl(url, context, sessionAssets, callerResolver) {
120
- const matchedAsset =
121
- CircuitJsonCadModelAssetResolver.#matchingSessionAsset(
315
+ static #resolveModelUrl(
316
+ url,
317
+ context,
318
+ sessionAssetIndex,
319
+ documentAssetIndex,
320
+ callerResolver
321
+ ) {
322
+ const matchedEntry =
323
+ CircuitJsonCadModelAssetResolver.#matchingIndexedEntry(
324
+ url,
325
+ context,
326
+ sessionAssetIndex
327
+ ) ||
328
+ CircuitJsonCadModelAssetResolver.#matchingIndexedEntry(
122
329
  url,
123
330
  context,
124
- sessionAssets
331
+ documentAssetIndex
125
332
  )
333
+ const matchedAsset = matchedEntry
334
+ ? CircuitJsonCadModelAssetResolver.#resolvedIndexedAsset(
335
+ matchedEntry
336
+ )
337
+ : null
126
338
  if (matchedAsset) {
127
- return matchedAsset
339
+ return CircuitJsonCadModelAssetResolver.#withCompanionResources(
340
+ matchedAsset,
341
+ matchedEntry,
342
+ url,
343
+ context,
344
+ sessionAssetIndex,
345
+ documentAssetIndex
346
+ )
128
347
  }
129
348
  return callerResolver ? callerResolver(url, context) : null
130
349
  }
131
350
 
132
351
  /**
133
- * Finds a session asset matching one CAD model URL.
352
+ * Finds the earliest indexed asset matching one CAD model URL.
134
353
  * @param {string} url Model URL.
135
354
  * @param {object} context Resolver context.
136
- * @param {object[]} sessionAssets Session assets.
355
+ * @param {Map<string, object>} assetIndex Asset alias index.
137
356
  * @returns {object | null}
138
357
  */
139
- static #matchingSessionAsset(url, context, sessionAssets) {
358
+ static #matchingIndexedEntry(url, context, assetIndex) {
140
359
  const candidates = CircuitJsonCadModelAssetResolver.#urlCandidates(
141
360
  url,
142
361
  context
143
362
  )
363
+ let match = null
364
+ for (const candidate of candidates) {
365
+ const indexed = assetIndex.exact.get(candidate)
366
+ if (indexed && (!match || indexed.index < match.index)) {
367
+ match = indexed
368
+ }
369
+ }
370
+ if (match) return match
371
+
372
+ for (const candidate of candidates) {
373
+ const indexed = assetIndex.folded.get(candidate.toLowerCase())
374
+ if (
375
+ indexed &&
376
+ indexed !== AMBIGUOUS_ASSET &&
377
+ (!match || indexed.index < match.index)
378
+ ) {
379
+ match = indexed
380
+ }
381
+ }
382
+ return match
383
+ }
384
+
385
+ /**
386
+ * Attaches referenced local sidecars without enabling implicit networking.
387
+ * @param {object} matchedAsset Resolved main model asset.
388
+ * @param {object} matchedEntry Indexed main model entry.
389
+ * @param {string} url Requested main model path.
390
+ * @param {object} context Model resolver context.
391
+ * @param {Map<string, object>} sessionAssetIndex Session asset index.
392
+ * @param {Map<string, object>} documentAssetIndex Document asset index.
393
+ * @returns {object}
394
+ */
395
+ static #withCompanionResources(
396
+ matchedAsset,
397
+ matchedEntry,
398
+ url,
399
+ context,
400
+ sessionAssetIndex,
401
+ documentAssetIndex
402
+ ) {
403
+ const format = String(
404
+ CircuitJsonCadModelAssetResolver.#ownData(context, 'format') || ''
405
+ ).toLowerCase()
406
+ const references =
407
+ CircuitJsonCadModelAssetResolver.#companionReferences(
408
+ matchedAsset,
409
+ format
410
+ )
411
+ if (!references.length) return matchedAsset
412
+
413
+ const mainPath =
414
+ PcbScene3dModelIdentity.projectPath(matchedAsset) || String(url)
415
+ const baseDirectory =
416
+ mainPath.includes('/') && !/^[a-z][a-z\d+.-]*:/iu.test(mainPath)
417
+ ? mainPath.slice(0, mainPath.lastIndexOf('/') + 1)
418
+ : ''
419
+ const companions = []
420
+ const seen = new Set()
421
+ for (const reference of references) {
422
+ const path = CircuitJsonCadModelAssetResolver.#safeCompanionPath(
423
+ reference,
424
+ baseDirectory
425
+ )
426
+ if (!path || seen.has(path)) continue
427
+ const entry = CircuitJsonCadModelAssetResolver.#indexedCompanion(
428
+ path,
429
+ sessionAssetIndex,
430
+ documentAssetIndex
431
+ )
432
+ if (!entry || entry === matchedEntry) continue
433
+ const asset =
434
+ CircuitJsonCadModelAssetResolver.#resolvedIndexedAsset(entry)
435
+ if (!asset) continue
436
+ companions.push({
437
+ ...PcbScene3dDescriptorSafeRecord.copy(asset),
438
+ uri: reference
439
+ })
440
+ seen.add(path)
441
+ }
442
+ if (!companions.length) return matchedAsset
443
+
444
+ const field = format === 'gltf' ? 'externalBuffers' : 'resources'
445
+ const existing = CircuitJsonCadModelAssetResolver.#denseArray(
446
+ CircuitJsonCadModelAssetResolver.#ownData(matchedAsset, field)
447
+ )
448
+ return {
449
+ ...PcbScene3dDescriptorSafeRecord.copy(matchedAsset),
450
+ [field]: [...existing, ...companions]
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Extracts referenced local companions from text-capable model formats.
456
+ * @param {object} asset Main model asset.
457
+ * @param {string} format Model format.
458
+ * @returns {string[]}
459
+ */
460
+ static #companionReferences(asset, format) {
461
+ const text = CircuitJsonCadModelAssetResolver.#assetText(asset)
462
+ if (!text) return []
463
+ if (format === 'gltf') {
464
+ try {
465
+ const document = JSON.parse(text)
466
+ return CircuitJsonCadModelAssetResolver.#denseArray(
467
+ CircuitJsonCadModelAssetResolver.#ownData(
468
+ document,
469
+ 'buffers'
470
+ )
471
+ )
472
+ .map((buffer) =>
473
+ String(
474
+ CircuitJsonCadModelAssetResolver.#ownData(
475
+ buffer,
476
+ 'uri'
477
+ ) || ''
478
+ ).trim()
479
+ )
480
+ .filter(
481
+ (reference) =>
482
+ reference && !reference.startsWith('data:')
483
+ )
484
+ } catch {
485
+ return []
486
+ }
487
+ }
488
+ if (format === 'obj') {
489
+ return [...text.matchAll(/^\s*mtllib\s+(.+?)\s*$/gimu)]
490
+ .map((match) =>
491
+ String(match[1] || '')
492
+ .trim()
493
+ .replace(/^(['"])(.*)\1$/u, '$2')
494
+ )
495
+ .filter(Boolean)
496
+ }
497
+ if (format === 'wrl' || format === 'vrml') {
498
+ const references = []
499
+ for (const block of text.matchAll(
500
+ /ImageTexture\s*\{[\s\S]*?\}/giu
501
+ )) {
502
+ for (const match of String(block[0] || '').matchAll(
503
+ /(['"])([^'"]+)\1/gu
504
+ )) {
505
+ const reference = String(match[2] || '').trim()
506
+ if (reference) references.push(reference)
507
+ }
508
+ }
509
+ return references
510
+ }
511
+ return []
512
+ }
513
+
514
+ /**
515
+ * Reads resident asset content as UTF-8 text without invoking accessors.
516
+ * @param {object} asset Asset candidate.
517
+ * @returns {string}
518
+ */
519
+ static #assetText(asset) {
520
+ for (const key of ['payloadText', 'data']) {
521
+ const value = CircuitJsonCadModelAssetResolver.#ownData(asset, key)
522
+ if (typeof value === 'string') return value
523
+ }
524
+ for (const key of ['payloadBytes', 'bytes', 'data']) {
525
+ const bytes = CircuitJsonCadModelAssetResolver.#bytes(
526
+ CircuitJsonCadModelAssetResolver.#ownData(asset, key)
527
+ )
528
+ if (bytes) return new TextDecoder().decode(bytes)
529
+ }
530
+ return ''
531
+ }
532
+
533
+ /**
534
+ * Resolves one path-exact companion with session priority.
535
+ * @param {string} path Safe project-relative companion path.
536
+ * @param {Map<string, object>} sessionAssetIndex Session asset index.
537
+ * @param {Map<string, object>} documentAssetIndex Document asset index.
538
+ * @returns {object | null}
539
+ */
540
+ static #indexedCompanion(path, sessionAssetIndex, documentAssetIndex) {
541
+ const key = CircuitJsonCadModelAssetResolver.#key(path)
144
542
  return (
145
- sessionAssets.find((asset) =>
146
- CircuitJsonCadModelAssetResolver.#assetMatches(
543
+ CircuitJsonCadModelAssetResolver.#indexedAsset(
544
+ sessionAssetIndex,
545
+ key
546
+ ) ||
547
+ CircuitJsonCadModelAssetResolver.#indexedAsset(
548
+ documentAssetIndex,
549
+ key
550
+ ) ||
551
+ null
552
+ )
553
+ }
554
+
555
+ /**
556
+ * Resolves an exact alias or one unambiguous case-insensitive fallback.
557
+ * @param {{ exact: Map<string, object>, folded: Map<string, object | symbol> }} index Asset index.
558
+ * @param {string} key Exact normalized key.
559
+ * @returns {object | null} Indexed asset entry.
560
+ */
561
+ static #indexedAsset(index, key) {
562
+ const exact = index.exact.get(key)
563
+ if (exact) return exact
564
+ const folded = index.folded.get(key.toLowerCase())
565
+ return folded && folded !== AMBIGUOUS_ASSET ? folded : null
566
+ }
567
+
568
+ /**
569
+ * Resolves a safe project-relative sidecar path without parent traversal.
570
+ * @param {string} reference Referenced resource path.
571
+ * @param {string} baseDirectory Main model directory.
572
+ * @returns {string}
573
+ */
574
+ static #safeCompanionPath(reference, baseDirectory) {
575
+ const normalized = String(reference || '')
576
+ .trim()
577
+ .replaceAll('\\', '/')
578
+ let decoded = normalized
579
+ try {
580
+ decoded = decodeURIComponent(normalized)
581
+ } catch {
582
+ return ''
583
+ }
584
+ if (decoded.split('/').some((segment) => segment === '..')) {
585
+ return ''
586
+ }
587
+ return PcbScene3dModelContent.safeProjectPath(normalized, baseDirectory)
588
+ }
589
+
590
+ /**
591
+ * Returns a byte view for one resident binary payload.
592
+ * @param {unknown} value Binary payload candidate.
593
+ * @returns {Uint8Array | null}
594
+ */
595
+ static #bytes(value) {
596
+ if (value instanceof Uint8Array) return value
597
+ if (value instanceof ArrayBuffer) return new Uint8Array(value)
598
+ if (ArrayBuffer.isView(value)) {
599
+ return new Uint8Array(
600
+ value.buffer,
601
+ value.byteOffset,
602
+ value.byteLength
603
+ )
604
+ }
605
+ return null
606
+ }
607
+
608
+ /**
609
+ * Builds one descriptor-safe, first-match-preserving asset alias index.
610
+ * @param {object[]} assets Asset rows.
611
+ * @param {boolean} [canonical] Whether rows use the canonical toolkit asset contract.
612
+ * @returns {{ exact: Map<string, object>, folded: Map<string, object | symbol> }}
613
+ */
614
+ static #assetIndex(assets, canonical = false) {
615
+ const exact = new Map()
616
+ const folded = new Map()
617
+ assets.forEach((asset, assetIndex) => {
618
+ const entry = {
619
+ asset,
620
+ canonical,
621
+ index: assetIndex,
622
+ materialized: false,
623
+ resolvedAsset: null
624
+ }
625
+ for (const alias of CircuitJsonCadModelAssetResolver.#assetAliases(
626
+ asset
627
+ )) {
628
+ if (!exact.has(alias)) exact.set(alias, entry)
629
+ const foldedAlias = alias.toLowerCase()
630
+ const previous = folded.get(foldedAlias)
631
+ if (!previous) {
632
+ folded.set(foldedAlias, entry)
633
+ } else if (previous !== entry) {
634
+ folded.set(foldedAlias, AMBIGUOUS_ASSET)
635
+ }
636
+ }
637
+ })
638
+ return { exact, folded }
639
+ }
640
+
641
+ /**
642
+ * Copies a dense ordinary array without invoking element accessors.
643
+ * @param {unknown} value Array candidate.
644
+ * @returns {unknown[]}
645
+ */
646
+ static #denseArray(value) {
647
+ let descriptors
648
+ let prototype
649
+ try {
650
+ if (!Array.isArray(value)) return []
651
+ descriptors = Object.getOwnPropertyDescriptors(value)
652
+ prototype = Object.getPrototypeOf(value)
653
+ } catch {
654
+ return []
655
+ }
656
+ const length = descriptors.length?.value
657
+ if (
658
+ prototype !== Array.prototype ||
659
+ !Number.isSafeInteger(length) ||
660
+ length < 0
661
+ ) {
662
+ return []
663
+ }
664
+
665
+ const result = new Array(length)
666
+ for (let index = 0; index < length; index += 1) {
667
+ const descriptor = descriptors[String(index)]
668
+ if (
669
+ !descriptor ||
670
+ descriptor.enumerable !== true ||
671
+ !Object.hasOwn(descriptor, 'value')
672
+ ) {
673
+ return []
674
+ }
675
+ result[index] = descriptor.value
676
+ }
677
+ return result
678
+ }
679
+
680
+ /**
681
+ * Materializes a canonical asset only when its first reference is used.
682
+ * @param {{ asset: object, canonical: boolean, materialized: boolean, resolvedAsset: object | null }} entry Indexed asset entry.
683
+ * @returns {object | null}
684
+ */
685
+ static #resolvedIndexedAsset(entry) {
686
+ if (!entry.materialized) {
687
+ entry.resolvedAsset =
688
+ CircuitJsonCadModelAssetResolver.#canonicalAsset(entry.asset) ||
689
+ entry.asset
690
+ entry.materialized = true
691
+ }
692
+ return entry.resolvedAsset
693
+ }
694
+
695
+ /**
696
+ * Materializes one validated canonical asset into safe resolver metadata.
697
+ * @param {unknown} asset Canonical asset candidate.
698
+ * @returns {Record<string, unknown> | null}
699
+ */
700
+ static #canonicalAsset(asset) {
701
+ try {
702
+ const prepared = ToolkitAsset.create(asset)
703
+ return {
704
+ ...PcbScene3dDescriptorSafeRecord.copy(prepared),
705
+ data: prepared.data
706
+ }
707
+ } catch {
708
+ return null
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Reads exact legacy and canonical path aliases without invoking accessors.
714
+ * @param {object} asset Asset row.
715
+ * @returns {Set<string>}
716
+ */
717
+ static #assetAliases(asset) {
718
+ const source = CircuitJsonCadModelAssetResolver.#ownData(
719
+ asset,
720
+ 'source'
721
+ )
722
+ return new Set(
723
+ [
724
+ CircuitJsonCadModelAssetResolver.#ownData(
147
725
  asset,
148
- candidates
149
- )
150
- ) || null
726
+ 'relativePath'
727
+ ),
728
+ CircuitJsonCadModelAssetResolver.#ownData(asset, 'sourceUrl'),
729
+ CircuitJsonCadModelAssetResolver.#ownData(asset, 'url'),
730
+ CircuitJsonCadModelAssetResolver.#ownData(asset, 'name'),
731
+ CircuitJsonCadModelAssetResolver.#ownData(source, 'entryName'),
732
+ CircuitJsonCadModelAssetResolver.#ownData(
733
+ source,
734
+ 'projectRelativePath'
735
+ ),
736
+ CircuitJsonCadModelAssetResolver.#ownData(
737
+ source,
738
+ 'project_relative_path'
739
+ ),
740
+ CircuitJsonCadModelAssetResolver.#ownData(
741
+ source,
742
+ 'relativePath'
743
+ ),
744
+ CircuitJsonCadModelAssetResolver.#ownData(source, 'url'),
745
+ CircuitJsonCadModelAssetResolver.#ownData(source, 'uri')
746
+ ]
747
+ .map((value) => CircuitJsonCadModelAssetResolver.#key(value))
748
+ .filter(Boolean)
151
749
  )
152
750
  }
153
751
 
@@ -158,11 +756,22 @@ export class CircuitJsonCadModelAssetResolver {
158
756
  * @returns {Set<string>}
159
757
  */
160
758
  static #urlCandidates(url, context) {
759
+ const cadComponent = CircuitJsonCadModelAssetResolver.#ownData(
760
+ context,
761
+ 'cadComponent'
762
+ )
763
+ const modelAsset = CircuitJsonCadModelAssetResolver.#ownData(
764
+ cadComponent,
765
+ 'model_asset'
766
+ )
161
767
  return new Set(
162
768
  [
163
769
  url,
164
- context?.cadComponent?.model_asset?.project_relative_path,
165
- context?.cadComponent?.model_asset?.url
770
+ CircuitJsonCadModelAssetResolver.#ownData(
771
+ modelAsset,
772
+ 'project_relative_path'
773
+ ),
774
+ CircuitJsonCadModelAssetResolver.#ownData(modelAsset, 'url')
166
775
  ]
167
776
  .map((value) => CircuitJsonCadModelAssetResolver.#key(value))
168
777
  .filter(Boolean)
@@ -170,45 +779,21 @@ export class CircuitJsonCadModelAssetResolver {
170
779
  }
171
780
 
172
781
  /**
173
- * Returns true when a session asset matches a model URL.
174
- * @param {object} asset Session asset.
175
- * @param {Set<string>} candidates URL candidates.
176
- * @returns {boolean}
177
- */
178
- static #assetMatches(asset, candidates) {
179
- return [
180
- asset?.relativePath,
181
- asset?.sourceUrl,
182
- asset?.url,
183
- asset?.name
184
- ].some((value) =>
185
- candidates.has(CircuitJsonCadModelAssetResolver.#key(value))
186
- )
187
- }
188
-
189
- /**
190
- * Resolves a supported model format from asset metadata.
191
- * @param {object} asset Model asset metadata.
192
- * @returns {string}
782
+ * Reads one own data property without invoking accessors.
783
+ * @param {unknown} value Record candidate.
784
+ * @param {PropertyKey} key Property key.
785
+ * @returns {unknown}
193
786
  */
194
- static #format(asset) {
195
- const text = String(
196
- asset.format ||
197
- asset.mimetype ||
198
- asset.project_relative_path ||
199
- asset.url ||
200
- ''
201
- ).toLowerCase()
202
- if (/\bglb\b|\.glb(?:[?#]|$)/u.test(text)) return 'glb'
203
- if (/\bgltf\b|\.gltf(?:[?#]|$)/u.test(text)) return 'gltf'
204
- if (/\b3mf\b|\.3mf(?:[?#]|$)/u.test(text)) return '3mf'
205
- if (/\bobj\b|\.obj(?:[?#]|$)/u.test(text)) return 'obj'
206
- if (/\bstl\b|\.stl(?:[?#]|$)/u.test(text)) return 'stl'
207
- if (/\bwrl\b|vrml|\.wrl(?:[?#]|$)/u.test(text)) return 'wrl'
208
- if (/step|stp|\.step(?:[?#]|$)|\.stp(?:[?#]|$)/u.test(text)) {
209
- return 'step'
787
+ static #ownData(value, key) {
788
+ if (!value || typeof value !== 'object') return undefined
789
+ try {
790
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
791
+ return descriptor && Object.hasOwn(descriptor, 'value')
792
+ ? descriptor.value
793
+ : undefined
794
+ } catch {
795
+ return undefined
210
796
  }
211
- return ''
212
797
  }
213
798
 
214
799
  /**
@@ -227,7 +812,11 @@ export class CircuitJsonCadModelAssetResolver {
227
812
  'model_gltf_url',
228
813
  'model_stl_url',
229
814
  'model_obj_url'
230
- ].some((field) => String(element?.[field] || '').trim())
815
+ ].some((field) =>
816
+ String(
817
+ CircuitJsonCadModelAssetResolver.#ownData(element, field) || ''
818
+ ).trim()
819
+ )
231
820
  }
232
821
 
233
822
  /**
@@ -237,11 +826,36 @@ export class CircuitJsonCadModelAssetResolver {
237
826
  */
238
827
  static #elements(documentModel) {
239
828
  if (Array.isArray(documentModel)) return documentModel
240
- if (Array.isArray(documentModel?.elements))
241
- return documentModel.elements
242
- if (Array.isArray(documentModel?.circuitJson)) {
243
- return documentModel.circuitJson
829
+ if (
830
+ CircuitJsonCadModelAssetResolver.#isContext(documentModel) &&
831
+ Array.isArray(documentModel.document?.model)
832
+ ) {
833
+ return documentModel.document.model
244
834
  }
835
+ const wrappedDocument = CircuitJsonCadModelAssetResolver.#ownData(
836
+ documentModel,
837
+ 'document'
838
+ )
839
+ const wrappedModel = CircuitJsonCadModelAssetResolver.#ownData(
840
+ wrappedDocument,
841
+ 'model'
842
+ )
843
+ if (Array.isArray(wrappedModel)) return wrappedModel
844
+ const model = CircuitJsonCadModelAssetResolver.#ownData(
845
+ documentModel,
846
+ 'model'
847
+ )
848
+ if (Array.isArray(model)) return model
849
+ const elements = CircuitJsonCadModelAssetResolver.#ownData(
850
+ documentModel,
851
+ 'elements'
852
+ )
853
+ if (Array.isArray(elements)) return elements
854
+ const circuitJson = CircuitJsonCadModelAssetResolver.#ownData(
855
+ documentModel,
856
+ 'circuitJson'
857
+ )
858
+ if (Array.isArray(circuitJson)) return circuitJson
245
859
  return []
246
860
  }
247
861
 
@@ -251,11 +865,11 @@ export class CircuitJsonCadModelAssetResolver {
251
865
  * @returns {string}
252
866
  */
253
867
  static #key(value) {
868
+ if (typeof value !== 'string' && typeof value !== 'number') return ''
254
869
  return String(value || '')
255
870
  .trim()
256
871
  .split(/[?#]/u)[0]
257
872
  .replace(/^https?:\/\/[^/]+\//iu, '')
258
873
  .replace(/^\/+/u, '')
259
- .toLowerCase()
260
874
  }
261
875
  }