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
@@ -2,6 +2,9 @@ import { zipSync } from 'fflate'
2
2
  import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
3
3
  import { PcbAssemblyModelMeshLoader } from './PcbAssemblyModelMeshLoader.mjs'
4
4
  import { PcbAssemblyStepWriter } from './PcbAssemblyStepWriter.mjs'
5
+ import { PcbModelArchiveSourceBundle } from './PcbModelArchiveSourceBundle.mjs'
6
+ import { PcbScene3dModelContent } from './PcbScene3dModelContent.mjs'
7
+ import { PcbScene3dModelIdentity } from './PcbScene3dModelIdentity.mjs'
5
8
 
6
9
  const STATIC_BODY_COLOR = [0.5, 0.5, 0.5]
7
10
 
@@ -12,10 +15,13 @@ const STATIC_BODY_COLOR = [0.5, 0.5, 0.5]
12
15
  export class PcbModelArchiveExporter {
13
16
  /**
14
17
  * Builds one ZIP archive from the resolved scene description.
15
- * @param {{ archiveBaseName?: string, sceneDescription?: { components?: any[], externalPlacements?: any[], staticBodyPlacements?: any[] }, modelMeshLoader?: ((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader, includeRawModels?: boolean, includeStitchedComponents?: boolean, stitchedDesignators?: string[] }} [options]
18
+ * @param {{ archiveBaseName?: string, sceneDescription?: { components?: any[], externalPlacements?: any[], staticBodyPlacements?: any[] }, modelMeshLoader?: ((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader, modelLoaderOptions?: object, includeRawModels?: boolean, includeStitchedComponents?: boolean, stitchedDesignators?: string[] }} [options]
16
19
  * @returns {Promise<{ archiveName: string, archiveBytes: Uint8Array, exportedEntries: { archivePath: string, pattern: string, designator: string, format: string, modelName: string, kind?: string }[], skippedEntries: { designator: string, reason: string }[] }>}
17
20
  */
18
21
  static async buildArchive(options = {}) {
22
+ const modelLoaderOptions = PcbScene3dModelContent.createFetchScope(
23
+ options.modelLoaderOptions
24
+ )
19
25
  const archiveName = PcbModelArchiveExporter.#resolveArchiveName(
20
26
  options.archiveBaseName
21
27
  )
@@ -28,18 +34,20 @@ export class PcbModelArchiveExporter {
28
34
  const exportedEntries = []
29
35
  const skippedEntries = []
30
36
  const archiveInput = {}
37
+ const rawBundleDirectories = new Set()
31
38
 
32
39
  for (const entry of resolvedEntries) {
33
40
  try {
34
- const archivePath = PcbModelArchiveExporter.#resolveArchivePath(
41
+ const bundle = await PcbModelArchiveSourceBundle.build(
35
42
  entry,
36
- exportedEntries
43
+ rawBundleDirectories,
44
+ modelLoaderOptions
37
45
  )
38
- const modelBytes =
39
- await PcbModelArchiveExporter.#readModelBytes(entry.model)
40
- archiveInput[archivePath] = modelBytes
46
+ Object.assign(archiveInput, bundle.files)
41
47
  exportedEntries.push({
42
- archivePath,
48
+ archivePath: bundle.archivePath,
49
+ bundleDirectory: bundle.bundleDirectory,
50
+ companionPaths: bundle.companionPaths,
43
51
  pattern: entry.pattern,
44
52
  designator: entry.designator,
45
53
  format: entry.model.format,
@@ -61,7 +69,8 @@ export class PcbModelArchiveExporter {
61
69
  : await PcbModelArchiveExporter.#buildStitchedEntries(
62
70
  options.sceneDescription,
63
71
  options.modelMeshLoader,
64
- options.stitchedDesignators
72
+ options.stitchedDesignators,
73
+ modelLoaderOptions
65
74
  )
66
75
  skippedEntries.push(...stitchedEntries.skippedEntries)
67
76
  for (const entry of stitchedEntries.entries) {
@@ -197,12 +206,14 @@ export class PcbModelArchiveExporter {
197
206
  * @param {{ components?: object[], externalPlacements?: object[], staticBodyPlacements?: object[] } | undefined} sceneDescription Scene data.
198
207
  * @param {((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader | undefined} modelMeshLoader Mesh loader override.
199
208
  * @param {string[] | undefined} stitchedDesignators Optional designator filter.
209
+ * @param {object | undefined} modelLoaderOptions Shared model loading policy.
200
210
  * @returns {Promise<{ entries: { pattern: string, designator: string, modelName: string, payloadText: string }[], skippedEntries: { designator: string, reason: string }[] }>}
201
211
  */
202
212
  static async #buildStitchedEntries(
203
213
  sceneDescription,
204
214
  modelMeshLoader,
205
- stitchedDesignators
215
+ stitchedDesignators,
216
+ modelLoaderOptions
206
217
  ) {
207
218
  const candidates = PcbModelArchiveExporter.#collectStitchedCandidates(
208
219
  sceneDescription,
@@ -212,8 +223,10 @@ export class PcbModelArchiveExporter {
212
223
  return { entries: [], skippedEntries: [] }
213
224
  }
214
225
 
215
- const loader =
216
- PcbModelArchiveExporter.#resolveModelMeshLoader(modelMeshLoader)
226
+ const loader = PcbModelArchiveExporter.#resolveModelMeshLoader(
227
+ modelMeshLoader,
228
+ modelLoaderOptions
229
+ )
217
230
  const ownsLoader =
218
231
  !(
219
232
  typeof modelMeshLoader === 'function' ||
@@ -473,9 +486,10 @@ export class PcbModelArchiveExporter {
473
486
  /**
474
487
  * Resolves a mesh loader for generated stitched components.
475
488
  * @param {((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader | undefined} modelMeshLoader Loader option.
489
+ * @param {object | undefined} modelLoaderOptions Shared model loading policy.
476
490
  * @returns {((placement: object) => Promise<object | object[]> | object | object[]) | PcbAssemblyModelMeshLoader}
477
491
  */
478
- static #resolveModelMeshLoader(modelMeshLoader) {
492
+ static #resolveModelMeshLoader(modelMeshLoader, modelLoaderOptions) {
479
493
  if (
480
494
  typeof modelMeshLoader === 'function' ||
481
495
  modelMeshLoader instanceof PcbAssemblyModelMeshLoader
@@ -483,7 +497,7 @@ export class PcbModelArchiveExporter {
483
497
  return modelMeshLoader
484
498
  }
485
499
 
486
- return new PcbAssemblyModelMeshLoader()
500
+ return new PcbAssemblyModelMeshLoader(modelLoaderOptions)
487
501
  }
488
502
 
489
503
  /**
@@ -573,36 +587,6 @@ export class PcbModelArchiveExporter {
573
587
  return componentByDesignator
574
588
  }
575
589
 
576
- /**
577
- * Resolves one archive file name for the exported model.
578
- * @param {{ pattern: string, model: { format?: string } }} entry
579
- * @param {{ archivePath: string }[]} exportedEntries
580
- * @returns {string}
581
- */
582
- static #resolveArchivePath(entry, exportedEntries) {
583
- const sanitizedPattern = PcbModelArchiveExporter.#sanitizeFileToken(
584
- entry.pattern
585
- )
586
- const extension = PcbModelArchiveExporter.#resolveExtension(
587
- entry.model?.format
588
- )
589
- const basePath = sanitizedPattern + '.' + extension
590
- let archivePath = basePath
591
- let duplicateIndex = 2
592
-
593
- while (
594
- exportedEntries.some(
595
- (exportedEntry) => exportedEntry.archivePath === archivePath
596
- )
597
- ) {
598
- archivePath =
599
- sanitizedPattern + '--' + duplicateIndex + '.' + extension
600
- duplicateIndex += 1
601
- }
602
-
603
- return archivePath
604
- }
605
-
606
590
  /**
607
591
  * Resolves one archive path for a stitched component STEP.
608
592
  * @param {{ designator: string }} entry Stitched entry.
@@ -634,28 +618,6 @@ export class PcbModelArchiveExporter {
634
618
  return archivePath
635
619
  }
636
620
 
637
- /**
638
- * Reads one resolved model into ZIP-ready bytes.
639
- * @param {{ origin?: string, payloadText?: string, file?: Blob | File | null }} model
640
- * @returns {Promise<Uint8Array>}
641
- */
642
- static async #readModelBytes(model) {
643
- if (model?.origin === 'embedded') {
644
- const payloadText = String(model?.payloadText || '')
645
- if (!payloadText) {
646
- throw new Error('Embedded STEP payload is unavailable.')
647
- }
648
-
649
- return new TextEncoder().encode(payloadText)
650
- }
651
-
652
- if (typeof model?.file?.arrayBuffer === 'function') {
653
- return new Uint8Array(await model.file.arrayBuffer())
654
- }
655
-
656
- throw new Error('Session model bytes are unavailable.')
657
- }
658
-
659
621
  /**
660
622
  * Resolves one user-facing archive name from the provided base name.
661
623
  * @param {string | undefined} archiveBaseName
@@ -704,53 +666,7 @@ export class PcbModelArchiveExporter {
704
666
  * @returns {string}
705
667
  */
706
668
  static #resolveModelIdentity(model) {
707
- if (String(model?.origin || '') === 'embedded') {
708
- return [
709
- 'embedded',
710
- String(model?.sourceStream || ''),
711
- String(model?.name || ''),
712
- String(model?.checksum || ''),
713
- String(model?.format || '')
714
- ].join('::')
715
- }
716
-
717
- return [
718
- 'session',
719
- String(model?.relativePath || ''),
720
- String(model?.name || ''),
721
- String(model?.format || '')
722
- ].join('::')
723
- }
724
-
725
- /**
726
- * Resolves a safe archive extension from the model format.
727
- * @param {string | undefined} format
728
- * @returns {string}
729
- */
730
- static #resolveExtension(format) {
731
- const normalizedFormat = String(format || '')
732
- .trim()
733
- .toLowerCase()
734
- if (
735
- [
736
- 'step',
737
- 'stp',
738
- 'wrl',
739
- 'vrml',
740
- 'glb',
741
- 'gltf',
742
- 'stl',
743
- 'obj'
744
- ].includes(normalizedFormat)
745
- ) {
746
- return normalizedFormat === 'stp'
747
- ? 'step'
748
- : normalizedFormat === 'vrml'
749
- ? 'wrl'
750
- : normalizedFormat
751
- }
752
-
753
- return 'step'
669
+ return PcbScene3dModelIdentity.resolve(model)
754
670
  }
755
671
 
756
672
  /**
@@ -0,0 +1,326 @@
1
+ import { PcbScene3dDescriptorSafeRecord } from './PcbScene3dDescriptorSafeRecord.mjs'
2
+ import { PcbScene3dModelContent } from './PcbScene3dModelContent.mjs'
3
+ import { PcbScene3dModelIdentity } from './PcbScene3dModelIdentity.mjs'
4
+
5
+ /**
6
+ * Builds one self-contained raw model subtree for ZIP export.
7
+ */
8
+ export class PcbModelArchiveSourceBundle {
9
+ /**
10
+ * Builds one main source and its safe relative companion resources.
11
+ * @param {{ pattern: string, model: object }} entry Raw model entry.
12
+ * @param {Set<string>} usedDirectories Existing bundle directories.
13
+ * @param {object} modelLoaderOptions Scoped loading policy.
14
+ * @returns {Promise<{ archivePath: string, bundleDirectory: string, companionPaths: string[], files: Record<string, Uint8Array> }>}
15
+ */
16
+ static async build(entry, usedDirectories, modelLoaderOptions) {
17
+ const bundleDirectory = PcbModelArchiveSourceBundle.#directory(
18
+ entry.pattern,
19
+ usedDirectories
20
+ )
21
+ const sourceName = PcbModelArchiveSourceBundle.#sourceName(entry.model)
22
+ const archivePath = bundleDirectory + '/' + sourceName
23
+ const mainBytes = await PcbScene3dModelContent.bytes(
24
+ entry.model,
25
+ modelLoaderOptions,
26
+ 'Resolved'
27
+ )
28
+ const files = { [archivePath]: mainBytes }
29
+ const companionPaths = await PcbModelArchiveSourceBundle.#companions(
30
+ entry.model,
31
+ mainBytes,
32
+ bundleDirectory,
33
+ modelLoaderOptions,
34
+ files
35
+ )
36
+ usedDirectories.add(bundleDirectory)
37
+ return { archivePath, bundleDirectory, companionPaths, files }
38
+ }
39
+
40
+ /**
41
+ * Reads and writes safe relative companion resources.
42
+ * @param {object} model Main model metadata.
43
+ * @param {Uint8Array} mainBytes Main source bytes.
44
+ * @param {string} bundleDirectory Unique archive directory.
45
+ * @param {object} options Scoped loading policy.
46
+ * @param {Record<string, Uint8Array>} files Bundle output.
47
+ * @returns {Promise<string[]>} Companion archive paths.
48
+ */
49
+ static async #companions(
50
+ model,
51
+ mainBytes,
52
+ bundleDirectory,
53
+ options,
54
+ files
55
+ ) {
56
+ const format = String(
57
+ PcbModelArchiveSourceBundle.#ownData(model, 'format') || ''
58
+ ).toLowerCase()
59
+ if (!['gltf', 'obj', 'wrl', 'vrml'].includes(format)) return []
60
+
61
+ const resources = PcbModelArchiveSourceBundle.#resources(model, format)
62
+ const byPath = new Map()
63
+ for (const resource of resources) {
64
+ const path = PcbModelArchiveSourceBundle.#resourcePath(resource)
65
+ if (path && !byPath.has(path)) byPath.set(path, resource)
66
+ }
67
+ const references = PcbModelArchiveSourceBundle.#references(
68
+ format,
69
+ new TextDecoder().decode(mainBytes)
70
+ )
71
+ const paths = new Set([...byPath.keys(), ...references])
72
+ const mainUrl = PcbModelArchiveSourceBundle.#mainUrl(model)
73
+ const companionPaths = []
74
+ for (const path of paths) {
75
+ const archivePath = bundleDirectory + '/' + path
76
+ if (Object.hasOwn(files, archivePath)) continue
77
+ const attached = byPath.get(path)
78
+ const resource = attached
79
+ ? {
80
+ ...PcbScene3dDescriptorSafeRecord.copy(attached),
81
+ mainModelUrl: mainUrl
82
+ }
83
+ : PcbModelArchiveSourceBundle.#remoteResource(path, mainUrl)
84
+ const bytes = await PcbScene3dModelContent.bytes(
85
+ resource,
86
+ options,
87
+ 'Model companion'
88
+ )
89
+ files[archivePath] = bytes
90
+ companionPaths.push(archivePath)
91
+ }
92
+ return companionPaths
93
+ }
94
+
95
+ /**
96
+ * Returns safe attached resource rows for one multi-file format.
97
+ * @param {object} model Model metadata.
98
+ * @param {string} format Model format.
99
+ * @returns {object[]} Resource rows.
100
+ */
101
+ static #resources(model, format) {
102
+ const field = format === 'gltf' ? 'externalBuffers' : 'resources'
103
+ return PcbModelArchiveSourceBundle.#array(
104
+ PcbModelArchiveSourceBundle.#ownData(model, field)
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Extracts safe relative resource references from a main source.
110
+ * @param {string} format Model format.
111
+ * @param {string} text Main source text.
112
+ * @returns {string[]} Safe unique paths.
113
+ */
114
+ static #references(format, text) {
115
+ let references = []
116
+ if (format === 'gltf') {
117
+ try {
118
+ const document = JSON.parse(text)
119
+ references = [
120
+ ...PcbModelArchiveSourceBundle.#array(document?.buffers),
121
+ ...PcbModelArchiveSourceBundle.#array(document?.images)
122
+ ].map((row) => row?.uri)
123
+ } catch {
124
+ references = []
125
+ }
126
+ } else if (format === 'obj') {
127
+ references = [...text.matchAll(/^\s*mtllib\s+(.+?)\s*$/gimu)].map(
128
+ (match) =>
129
+ String(match[1] || '')
130
+ .trim()
131
+ .replace(/^(['"])(.*)\1$/u, '$2')
132
+ )
133
+ } else {
134
+ for (const block of text.matchAll(
135
+ /ImageTexture\s*\{[\s\S]*?\}/giu
136
+ )) {
137
+ references.push(
138
+ ...[...String(block[0]).matchAll(/(['"])([^'"]+)\1/gu)].map(
139
+ (match) => match[2]
140
+ )
141
+ )
142
+ }
143
+ }
144
+ return [
145
+ ...new Set(
146
+ references
147
+ .map((reference) =>
148
+ PcbModelArchiveSourceBundle.#safePath(reference)
149
+ )
150
+ .filter(Boolean)
151
+ )
152
+ ]
153
+ }
154
+
155
+ /**
156
+ * Creates one explicitly fetchable missing companion.
157
+ * @param {string} path Safe relative resource path.
158
+ * @param {string} mainUrl Main model URL or path.
159
+ * @returns {object} Resource metadata.
160
+ */
161
+ static #remoteResource(path, mainUrl) {
162
+ return {
163
+ format: 'model-resource',
164
+ name: path.split('/').pop(),
165
+ resolvedUrl: PcbScene3dModelContent.resolveRelativeUrl(
166
+ path,
167
+ mainUrl
168
+ ),
169
+ mainModelUrl: mainUrl
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Resolves a safe relative path from one attached resource.
175
+ * @param {object} resource Resource metadata.
176
+ * @returns {string}
177
+ */
178
+ static #resourcePath(resource) {
179
+ return PcbModelArchiveSourceBundle.#safePath(
180
+ PcbModelArchiveSourceBundle.#ownData(resource, 'uri') ||
181
+ PcbModelArchiveSourceBundle.#ownData(resource, 'name')
182
+ )
183
+ }
184
+
185
+ /**
186
+ * Normalizes a project-relative archive path without traversal or schemes.
187
+ * @param {unknown} value Path candidate.
188
+ * @returns {string}
189
+ */
190
+ static #safePath(value) {
191
+ const raw = String(value || '')
192
+ .trim()
193
+ .split(/[?#]/u)[0]
194
+ return PcbScene3dModelContent.safeProjectPath(raw)
195
+ }
196
+
197
+ /**
198
+ * Selects one unique human-readable bundle directory.
199
+ * @param {string} pattern Component pattern.
200
+ * @param {Set<string>} used Existing directories.
201
+ * @returns {string}
202
+ */
203
+ static #directory(pattern, used) {
204
+ const base = PcbModelArchiveSourceBundle.#token(pattern || 'model')
205
+ let candidate = base
206
+ let suffix = 2
207
+ while (used.has(candidate)) {
208
+ candidate = base + '--' + suffix
209
+ suffix += 1
210
+ }
211
+ return candidate
212
+ }
213
+
214
+ /**
215
+ * Resolves the original source basename with a format fallback.
216
+ * @param {object} model Main model metadata.
217
+ * @returns {string}
218
+ */
219
+ static #sourceName(model) {
220
+ const sourcePath = PcbScene3dModelIdentity.projectPath(model)
221
+ let pathname = sourcePath
222
+ try {
223
+ pathname = new URL(sourcePath).pathname
224
+ } catch {
225
+ pathname = sourcePath
226
+ }
227
+ const candidate =
228
+ String(pathname || '')
229
+ .split(/[?#]/u)[0]
230
+ .replaceAll('\\', '/')
231
+ .split('/')
232
+ .filter(Boolean)
233
+ .pop() ||
234
+ String(PcbModelArchiveSourceBundle.#ownData(model, 'name') || '')
235
+ .replaceAll('\\', '/')
236
+ .split('/')
237
+ .pop()
238
+ const extension = PcbModelArchiveSourceBundle.#extension(
239
+ PcbModelArchiveSourceBundle.#ownData(model, 'format')
240
+ )
241
+ const safe = PcbModelArchiveSourceBundle.#token(candidate || 'model')
242
+ return safe.includes('.') ? safe : safe + '.' + extension
243
+ }
244
+
245
+ /**
246
+ * Resolves the main URL used for companion origin policy.
247
+ * @param {object} model Main model metadata.
248
+ * @returns {string}
249
+ */
250
+ static #mainUrl(model) {
251
+ return String(
252
+ PcbModelArchiveSourceBundle.#ownData(model, 'resolvedUrl') ||
253
+ PcbModelArchiveSourceBundle.#ownData(model, 'sourceUrl') ||
254
+ PcbScene3dModelIdentity.projectPath(model) ||
255
+ ''
256
+ ).trim()
257
+ }
258
+
259
+ /**
260
+ * Resolves a normalized fallback extension.
261
+ * @param {unknown} format Format candidate.
262
+ * @returns {string}
263
+ */
264
+ static #extension(format) {
265
+ const value = String(format || '').toLowerCase()
266
+ if (value === 'stp') return 'step'
267
+ if (value === 'vrml') return 'wrl'
268
+ return ['step', 'wrl', 'glb', 'gltf', 'stl', 'obj', '3mf'].includes(
269
+ value
270
+ )
271
+ ? value
272
+ : 'step'
273
+ }
274
+
275
+ /**
276
+ * Sanitizes one archive path segment.
277
+ * @param {unknown} value Segment candidate.
278
+ * @returns {string}
279
+ */
280
+ static #token(value) {
281
+ return (
282
+ String(value || '')
283
+ .replace(/[<>:"/\\|?*\u0000-\u001f]+/gu, '-')
284
+ .replace(/\s+/gu, ' ')
285
+ .trim() || 'model'
286
+ )
287
+ }
288
+
289
+ /**
290
+ * Converts descriptor-safe array or object resources to rows.
291
+ * @param {unknown} value Resource collection.
292
+ * @returns {object[]}
293
+ */
294
+ static #array(value) {
295
+ if (Array.isArray(value)) return value
296
+ return value && typeof value === 'object'
297
+ ? Object.entries(PcbScene3dDescriptorSafeRecord.copy(value)).map(
298
+ ([name, row]) => ({
299
+ name,
300
+ ...PcbScene3dDescriptorSafeRecord.copy(row)
301
+ })
302
+ )
303
+ : []
304
+ }
305
+
306
+ /**
307
+ * Reads one own data property without invoking accessors.
308
+ * @param {unknown} value Record candidate.
309
+ * @param {PropertyKey} key Property name.
310
+ * @returns {unknown}
311
+ */
312
+ static #ownData(value, key) {
313
+ if (!value || typeof value !== 'object') return undefined
314
+ try {
315
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
316
+ return descriptor && Object.hasOwn(descriptor, 'value')
317
+ ? descriptor.value
318
+ : undefined
319
+ } catch {
320
+ return undefined
321
+ }
322
+ }
323
+ }
324
+
325
+ Object.freeze(PcbModelArchiveSourceBundle.prototype)
326
+ Object.freeze(PcbModelArchiveSourceBundle)