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
@@ -0,0 +1,217 @@
1
+ import { PcbScene3dBufferAttributeFactory } from './PcbScene3dBufferAttributeFactory.mjs'
2
+
3
+ /**
4
+ * Converts normalized faceted assembly meshes into live Three.js groups.
5
+ */
6
+ export class PcbScene3dFacetedModelGroupBuilder {
7
+ /**
8
+ * Builds a Three.js group from normalized mesh rows.
9
+ * @param {any} THREE Three.js namespace.
10
+ * @param {object[]} meshes Normalized faceted meshes.
11
+ * @returns {any}
12
+ */
13
+ static build(THREE, meshes) {
14
+ const group = new THREE.Group()
15
+ for (const mesh of Array.isArray(meshes) ? meshes : []) {
16
+ const threeMesh = PcbScene3dFacetedModelGroupBuilder.#buildMesh(
17
+ THREE,
18
+ mesh
19
+ )
20
+ if (threeMesh) group.add(threeMesh)
21
+ }
22
+ if (!group.children.length) {
23
+ throw new Error('External model contains no renderable meshes.')
24
+ }
25
+ return group
26
+ }
27
+
28
+ /**
29
+ * Builds one Three.js mesh from normalized vertices and faces.
30
+ * @param {any} THREE Three.js namespace.
31
+ * @param {object} mesh Normalized faceted mesh.
32
+ * @returns {any | null}
33
+ */
34
+ static #buildMesh(THREE, mesh) {
35
+ const vertices = Array.isArray(mesh?.vertices) ? mesh.vertices : []
36
+ const indices = PcbScene3dFacetedModelGroupBuilder.#indices(mesh?.faces)
37
+ if (!vertices.length || !indices.length) return null
38
+
39
+ const geometry = new THREE.BufferGeometry()
40
+ geometry.setAttribute(
41
+ 'position',
42
+ PcbScene3dBufferAttributeFactory.createFloat32(
43
+ THREE,
44
+ vertices.flatMap((vertex) => [
45
+ Number(vertex?.[0] || 0),
46
+ Number(vertex?.[1] || 0),
47
+ Number(vertex?.[2] || 0)
48
+ ]),
49
+ 3
50
+ )
51
+ )
52
+ geometry.setIndex(
53
+ PcbScene3dBufferAttributeFactory.createUint32(THREE, indices, 1)
54
+ )
55
+ const colorAttribute = PcbScene3dFacetedModelGroupBuilder.#vertexColors(
56
+ mesh,
57
+ vertices
58
+ )
59
+ if (colorAttribute.values.length) {
60
+ geometry.setAttribute(
61
+ 'color',
62
+ PcbScene3dBufferAttributeFactory.createFloat32(
63
+ THREE,
64
+ colorAttribute.values,
65
+ colorAttribute.itemSize
66
+ )
67
+ )
68
+ }
69
+ geometry.computeVertexNormals()
70
+ geometry.computeBoundingSphere()
71
+
72
+ const result = new THREE.Mesh(
73
+ geometry,
74
+ PcbScene3dFacetedModelGroupBuilder.#material(
75
+ THREE,
76
+ mesh,
77
+ colorAttribute
78
+ )
79
+ )
80
+ result.name = String(mesh?.name || '')
81
+ return result
82
+ }
83
+
84
+ /**
85
+ * Triangulates polygon faces into a packed index array.
86
+ * @param {unknown} faces Polygon face rows.
87
+ * @returns {number[]}
88
+ */
89
+ static #indices(faces) {
90
+ const indices = []
91
+ for (const face of Array.isArray(faces) ? faces : []) {
92
+ if (!Array.isArray(face) || face.length < 3) continue
93
+ const first = Number(face[0])
94
+ for (let index = 1; index + 1 < face.length; index += 1) {
95
+ indices.push(
96
+ first,
97
+ Number(face[index]),
98
+ Number(face[index + 1])
99
+ )
100
+ }
101
+ }
102
+ return indices.filter((index) => Number.isInteger(index) && index >= 0)
103
+ }
104
+
105
+ /**
106
+ * Flattens optional RGB or RGBA vertex colors into RGB attributes.
107
+ * @param {object} mesh Normalized faceted mesh.
108
+ * @param {unknown[]} vertices Mesh vertices.
109
+ * @returns {{ values: number[], itemSize: number, transparent: boolean }}
110
+ */
111
+ static #vertexColors(mesh, vertices) {
112
+ if (!Array.isArray(mesh?.vertexColors)) {
113
+ return { values: [], itemSize: 3, transparent: false }
114
+ }
115
+ const fallback = PcbScene3dFacetedModelGroupBuilder.#color(mesh)
116
+ const itemSize = mesh.vertexColors.some(
117
+ (color) => Array.isArray(color) && color.length >= 4
118
+ )
119
+ ? 4
120
+ : 3
121
+ let transparent = false
122
+ const values = vertices.flatMap((_vertex, index) => {
123
+ const color = mesh.vertexColors[index]
124
+ const channels = [0, 1, 2].map((channel) =>
125
+ PcbScene3dFacetedModelGroupBuilder.#unit(
126
+ color?.[channel],
127
+ fallback[channel]
128
+ )
129
+ )
130
+ if (itemSize === 4) {
131
+ const alpha = PcbScene3dFacetedModelGroupBuilder.#unit(
132
+ color?.[3],
133
+ 1
134
+ )
135
+ transparent ||= alpha < 1
136
+ channels.push(alpha)
137
+ }
138
+ return channels
139
+ })
140
+ return { values, itemSize, transparent }
141
+ }
142
+
143
+ /**
144
+ * Creates a color- and opacity-preserving Three.js material.
145
+ * @param {any} THREE Three.js namespace.
146
+ * @param {object} mesh Normalized faceted mesh.
147
+ * @param {{ values: number[], transparent: boolean }} colorAttribute Vertex-color metadata.
148
+ * @returns {any}
149
+ */
150
+ static #material(THREE, mesh, colorAttribute) {
151
+ const color = PcbScene3dFacetedModelGroupBuilder.#color(mesh)
152
+ const opacity = PcbScene3dFacetedModelGroupBuilder.#opacity(mesh)
153
+ const options = {
154
+ color: new THREE.Color(color[0], color[1], color[2]),
155
+ opacity,
156
+ transparent: opacity < 1 || colorAttribute.transparent,
157
+ vertexColors: colorAttribute.values.length > 0
158
+ }
159
+ if (THREE.DoubleSide !== undefined) options.side = THREE.DoubleSide
160
+
161
+ const source = mesh?.material || {}
162
+ if (THREE.MeshPhongMaterial && Array.isArray(source.specularColor)) {
163
+ options.specular = new THREE.Color(
164
+ Number(source.specularColor[0] || 0),
165
+ Number(source.specularColor[1] || 0),
166
+ Number(source.specularColor[2] || 0)
167
+ )
168
+ options.shininess = Number(source.shininess || 30)
169
+ return new THREE.MeshPhongMaterial(options)
170
+ }
171
+ return new THREE.MeshStandardMaterial({
172
+ ...options,
173
+ roughness: 0.56,
174
+ metalness: 0.14
175
+ })
176
+ }
177
+
178
+ /**
179
+ * Resolves one normalized base color.
180
+ * @param {object} mesh Normalized faceted mesh.
181
+ * @returns {number[]}
182
+ */
183
+ static #color(mesh) {
184
+ const color = Array.isArray(mesh?.color)
185
+ ? mesh.color
186
+ : [0.78, 0.78, 0.78]
187
+ return [0, 1, 2].map((index) =>
188
+ PcbScene3dFacetedModelGroupBuilder.#unit(color[index], 0.78)
189
+ )
190
+ }
191
+
192
+ /**
193
+ * Resolves normalized material opacity.
194
+ * @param {object} mesh Normalized faceted mesh.
195
+ * @returns {number}
196
+ */
197
+ static #opacity(mesh) {
198
+ const colorAlpha = Array.isArray(mesh?.color) ? mesh.color[3] : null
199
+ return PcbScene3dFacetedModelGroupBuilder.#unit(
200
+ colorAlpha ?? mesh?.material?.alpha,
201
+ 1
202
+ )
203
+ }
204
+
205
+ /**
206
+ * Clamps one material channel into the unit interval.
207
+ * @param {unknown} value Channel value.
208
+ * @param {number} fallback Fallback channel value.
209
+ * @returns {number}
210
+ */
211
+ static #unit(value, fallback) {
212
+ const number = Number(value)
213
+ return Number.isFinite(number)
214
+ ? Math.min(Math.max(number, 0), 1)
215
+ : fallback
216
+ }
217
+ }
@@ -9,7 +9,7 @@ export class PcbScene3dGeometryZCompressor {
9
9
  * Rewrites mask-covered copper relief below exposed copper height.
10
10
  * @param {any | null} mesh Mesh with a position attribute.
11
11
  * @param {number} sourceCenterZ Original geometry center Z.
12
- * @param {{ centerOffsetMil?: number }} [options] Compression options.
12
+ * @param {{ centerOffsetMil?: number, thicknessMil?: number }} [options] Compression options.
13
13
  * @returns {void}
14
14
  */
15
15
  static compressMaskCoveredCopperMesh(mesh, sourceCenterZ, options = {}) {
@@ -19,7 +19,9 @@ export class PcbScene3dGeometryZCompressor {
19
19
  sourceCenterZ +
20
20
  MASK_COVERED_CENTER_OFFSET_MIL +
21
21
  Number(options?.centerOffsetMil || 0),
22
- MASK_COVERED_THICKNESS_MIL
22
+ Number.isFinite(Number(options?.thicknessMil))
23
+ ? Math.max(Number(options.thicknessMil), 0)
24
+ : MASK_COVERED_THICKNESS_MIL
23
25
  )
24
26
  }
25
27
 
@@ -10,7 +10,8 @@ export class PcbScene3dMaskCoveredCopperSideGroupBuilder {
10
10
  static #TRACK_COPPER_BLEND = 0.7
11
11
  static #FILL_RENDER_ORDER = 10
12
12
  static #TRACK_RENDER_ORDER = 12
13
- static #TRACK_CENTER_OFFSET_MIL = 0.16
13
+ static #TRACK_CENTER_OFFSET_MIL = 0.25
14
+ static #TRACK_THICKNESS_MIL = 1.05
14
15
 
15
16
  /**
16
17
  * Builds one side group from prepared mask-covered copper meshes.
@@ -39,6 +40,10 @@ export class PcbScene3dMaskCoveredCopperSideGroupBuilder {
39
40
  centerOffsetMil:
40
41
  PcbScene3dMaskCoveredCopperSideGroupBuilder
41
42
  .#TRACK_CENTER_OFFSET_MIL,
43
+ thicknessMil:
44
+ PcbScene3dMaskCoveredCopperSideGroupBuilder
45
+ .#TRACK_THICKNESS_MIL,
46
+ keepSideWalls: true,
42
47
  copperBlend:
43
48
  PcbScene3dMaskCoveredCopperSideGroupBuilder
44
49
  .#TRACK_COPPER_BLEND,
@@ -56,6 +61,10 @@ export class PcbScene3dMaskCoveredCopperSideGroupBuilder {
56
61
  centerOffsetMil:
57
62
  PcbScene3dMaskCoveredCopperSideGroupBuilder
58
63
  .#TRACK_CENTER_OFFSET_MIL,
64
+ thicknessMil:
65
+ PcbScene3dMaskCoveredCopperSideGroupBuilder
66
+ .#TRACK_THICKNESS_MIL,
67
+ keepSideWalls: true,
59
68
  copperBlend:
60
69
  PcbScene3dMaskCoveredCopperSideGroupBuilder
61
70
  .#TRACK_COPPER_BLEND,
@@ -92,7 +101,7 @@ export class PcbScene3dMaskCoveredCopperSideGroupBuilder {
92
101
  * @param {any | null} mesh Mesh to add.
93
102
  * @param {string} name Scene object name.
94
103
  * @param {number} z Source center Z.
95
- * @param {{ centerOffsetMil?: number, copperBlend?: number, renderOrder?: number }} [options] Presentation options.
104
+ * @param {{ centerOffsetMil?: number, thicknessMil?: number, keepSideWalls?: boolean, copperBlend?: number, renderOrder?: number }} [options] Presentation options.
96
105
  * @returns {void}
97
106
  */
98
107
  static #addCompressedMesh(group, mesh, name, z, options = {}) {
@@ -100,9 +109,14 @@ export class PcbScene3dMaskCoveredCopperSideGroupBuilder {
100
109
  return
101
110
  }
102
111
  PcbScene3dGeometryZCompressor.compressMaskCoveredCopperMesh(mesh, z, {
103
- centerOffsetMil: options.centerOffsetMil
112
+ centerOffsetMil: options.centerOffsetMil,
113
+ thicknessMil: options.thicknessMil
104
114
  })
105
- PcbScene3dMaskCoveredCopperSurfaceFilter.keepOuterSurface(mesh)
115
+ if (options.keepSideWalls === true) {
116
+ PcbScene3dMaskCoveredCopperSurfaceFilter.keepOuterRelief(mesh)
117
+ } else {
118
+ PcbScene3dMaskCoveredCopperSurfaceFilter.keepOuterSurface(mesh)
119
+ }
106
120
  mesh.name = name
107
121
  mesh.renderOrder = Number(options.renderOrder || 0)
108
122
  PcbScene3dMaskCoveredCopperSideGroupBuilder.#applyCopperTint(
@@ -10,6 +10,29 @@ export class PcbScene3dMaskCoveredCopperSurfaceFilter {
10
10
  * @returns {void}
11
11
  */
12
12
  static keepOuterSurface(mesh) {
13
+ PcbScene3dMaskCoveredCopperSurfaceFilter.#filterOuterTriangles(mesh, {
14
+ keepSideWalls: false
15
+ })
16
+ }
17
+
18
+ /**
19
+ * Removes the hidden underside while keeping shallow edge walls.
20
+ * @param {any | null} mesh Copper relief mesh.
21
+ * @returns {void}
22
+ */
23
+ static keepOuterRelief(mesh) {
24
+ PcbScene3dMaskCoveredCopperSurfaceFilter.#filterOuterTriangles(mesh, {
25
+ keepSideWalls: true
26
+ })
27
+ }
28
+
29
+ /**
30
+ * Keeps only triangles that are visible above the solder mask.
31
+ * @param {any | null} mesh Copper relief mesh.
32
+ * @param {{ keepSideWalls?: boolean }} options Filter options.
33
+ * @returns {void}
34
+ */
35
+ static #filterOuterTriangles(mesh, options) {
13
36
  const geometry = mesh?.geometry
14
37
  const position = geometry?.getAttribute?.('position')
15
38
  const source = position?.array
@@ -17,14 +40,16 @@ export class PcbScene3dMaskCoveredCopperSurfaceFilter {
17
40
  return
18
41
  }
19
42
 
20
- const maxZ = PcbScene3dMaskCoveredCopperSurfaceFilter.#maxZ(source)
43
+ const zBounds =
44
+ PcbScene3dMaskCoveredCopperSurfaceFilter.#zBounds(source)
21
45
  const filtered = []
22
46
  for (let index = 0; index + 8 < source.length; index += 9) {
23
47
  if (
24
- [2, 5, 8].every(
25
- (offset) =>
26
- Math.abs(source[index + offset] - maxZ) <=
27
- PcbScene3dMaskCoveredCopperSurfaceFilter.#Z_EPSILON
48
+ PcbScene3dMaskCoveredCopperSurfaceFilter.#keepsTriangle(
49
+ source,
50
+ index,
51
+ zBounds,
52
+ options
28
53
  )
29
54
  ) {
30
55
  filtered.push(...source.slice(index, index + 9))
@@ -50,15 +75,55 @@ export class PcbScene3dMaskCoveredCopperSurfaceFilter {
50
75
  }
51
76
 
52
77
  /**
53
- * Resolves the highest Z plane in one packed XYZ buffer.
78
+ * Checks whether one triangle should stay in the visible relief mesh.
79
+ * @param {ArrayLike<number>} source Position buffer.
80
+ * @param {number} index Triangle start index.
81
+ * @param {{ minZ: number, maxZ: number }} zBounds Geometry Z bounds.
82
+ * @param {{ keepSideWalls?: boolean }} options Filter options.
83
+ * @returns {boolean}
84
+ */
85
+ static #keepsTriangle(source, index, zBounds, options) {
86
+ const zValues = [2, 5, 8].map((offset) => source[index + offset])
87
+ const hasTop = zValues.some((z) =>
88
+ PcbScene3dMaskCoveredCopperSurfaceFilter.#matchesZ(z, zBounds.maxZ)
89
+ )
90
+ const hasBottom = zValues.some((z) =>
91
+ PcbScene3dMaskCoveredCopperSurfaceFilter.#matchesZ(z, zBounds.minZ)
92
+ )
93
+
94
+ if (hasTop && !hasBottom) {
95
+ return true
96
+ }
97
+
98
+ return options?.keepSideWalls === true && hasTop && hasBottom
99
+ }
100
+
101
+ /**
102
+ * Checks whether a Z value matches one target plane.
103
+ * @param {number} value Candidate Z.
104
+ * @param {number} target Target Z.
105
+ * @returns {boolean}
106
+ */
107
+ static #matchesZ(value, target) {
108
+ return (
109
+ Math.abs(Number(value) - Number(target)) <=
110
+ PcbScene3dMaskCoveredCopperSurfaceFilter.#Z_EPSILON
111
+ )
112
+ }
113
+
114
+ /**
115
+ * Resolves the lowest and highest Z planes in one packed XYZ buffer.
54
116
  * @param {ArrayLike<number>} source Position buffer.
55
- * @returns {number}
117
+ * @returns {{ minZ: number, maxZ: number }}
56
118
  */
57
- static #maxZ(source) {
119
+ static #zBounds(source) {
120
+ let minZ = Infinity
58
121
  let maxZ = -Infinity
59
122
  for (let index = 2; index < source.length; index += 3) {
60
- maxZ = Math.max(maxZ, Number(source[index]))
123
+ const z = Number(source[index])
124
+ minZ = Math.min(minZ, z)
125
+ maxZ = Math.max(maxZ, z)
61
126
  }
62
- return maxZ
127
+ return { minZ, maxZ }
63
128
  }
64
129
  }
@@ -0,0 +1,236 @@
1
+ import { PcbScene3dModelIdentity } from './PcbScene3dModelIdentity.mjs'
2
+ import { PcbScene3dModelFetchPolicy } from './PcbScene3dModelFetchPolicy.mjs'
3
+
4
+ /**
5
+ * Reads external-model payloads through one explicit local/network policy.
6
+ */
7
+ export class PcbScene3dModelContent {
8
+ /**
9
+ * Creates or reuses one bounded model fetch scope.
10
+ * @param {object} [options] Model loading options.
11
+ * @returns {object} Scoped options.
12
+ */
13
+ static createFetchScope(options = {}) {
14
+ return PcbScene3dModelFetchPolicy.scope(options)
15
+ }
16
+
17
+ /**
18
+ * Returns whether model URL fetching is explicitly available.
19
+ * @param {object} options Model loading options.
20
+ * @returns {boolean}
21
+ */
22
+ static canFetch(options) {
23
+ return PcbScene3dModelFetchPolicy.canFetch(options)
24
+ }
25
+
26
+ /**
27
+ * Reads one model as bytes from text, binary data, files, or opted-in URLs.
28
+ * @param {unknown} model External model metadata.
29
+ * @param {object} [options] Model loading policy.
30
+ * @param {string} [label] Human-readable format label.
31
+ * @returns {Promise<Uint8Array>}
32
+ */
33
+ static async bytes(model, options = {}, label = 'Model') {
34
+ for (const key of ['payloadText', 'text']) {
35
+ const value = PcbScene3dModelContent.#data(model, key)
36
+ if (typeof value === 'string') {
37
+ return new TextEncoder().encode(value)
38
+ }
39
+ }
40
+ for (const key of ['payloadBytes', 'bytes', 'data', 'file']) {
41
+ const value = PcbScene3dModelContent.#data(model, key)
42
+ if (typeof value === 'string') {
43
+ return new TextEncoder().encode(value)
44
+ }
45
+ const bytes = await PcbScene3dModelContent.#bytesFromValue(value)
46
+ if (bytes) return bytes
47
+ }
48
+ return PcbScene3dModelContent.#fetchBytes(model, options, label)
49
+ }
50
+
51
+ /**
52
+ * Reads one text-capable model without losing canonical string data.
53
+ * @param {unknown} model External model metadata.
54
+ * @param {object} [options] Model loading policy.
55
+ * @param {string} [label] Human-readable format label.
56
+ * @returns {Promise<string>}
57
+ */
58
+ static async text(model, options = {}, label = 'Model') {
59
+ for (const key of ['payloadText', 'text', 'data']) {
60
+ const value = PcbScene3dModelContent.#data(model, key)
61
+ if (typeof value === 'string') return value
62
+ }
63
+ const file = PcbScene3dModelContent.#data(model, 'file')
64
+ if (typeof file?.text === 'function') return file.text()
65
+ return new TextDecoder().decode(
66
+ await PcbScene3dModelContent.bytes(model, options, label)
67
+ )
68
+ }
69
+
70
+ /**
71
+ * Returns whether a model already carries local payload content.
72
+ * @param {unknown} model External model metadata.
73
+ * @returns {boolean}
74
+ */
75
+ static hasLocal(model) {
76
+ return [
77
+ 'payloadText',
78
+ 'text',
79
+ 'payloadBytes',
80
+ 'bytes',
81
+ 'data',
82
+ 'file'
83
+ ].some((key) => {
84
+ const value = PcbScene3dModelContent.#data(model, key)
85
+ return value !== null && value !== undefined
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Resolves a relative resource URL against absolute or project-relative bases.
91
+ * @param {string} uri Relative resource URI.
92
+ * @param {string} modelUrl Main model URL or path.
93
+ * @returns {string}
94
+ */
95
+ static resolveRelativeUrl(uri, modelUrl) {
96
+ const resource = String(uri || '').trim()
97
+ const base = String(modelUrl || '').trim()
98
+ if (!resource) return ''
99
+ try {
100
+ return new URL(resource, base).toString()
101
+ } catch {
102
+ if (/^(?:[a-z][a-z\d+.-]*:|\/)/iu.test(resource)) {
103
+ return resource
104
+ }
105
+ const basePath = base.split(/[?#]/u)[0]
106
+ const directory = basePath.includes('/')
107
+ ? basePath.slice(0, basePath.lastIndexOf('/') + 1)
108
+ : ''
109
+ return PcbScene3dModelContent.safeProjectPath(resource, directory)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Resolves a safe project-relative resource path without root traversal.
115
+ * @param {string} uri Resource URI.
116
+ * @param {string} [baseDirectory] Project-relative base directory.
117
+ * @returns {string}
118
+ */
119
+ static safeProjectPath(uri, baseDirectory = '') {
120
+ const value = String(uri || '')
121
+ .trim()
122
+ .replaceAll('\\', '/')
123
+ if (
124
+ !value ||
125
+ /^(?:[a-z][a-z\d+.-]*:|\/|#)/iu.test(value) ||
126
+ value.includes('\0')
127
+ ) {
128
+ return ''
129
+ }
130
+ const parts = []
131
+ const combined = String(baseDirectory || '') + value
132
+ for (const part of combined.split('/')) {
133
+ if (!part || part === '.') continue
134
+ if (part === '..') {
135
+ if (!parts.length) return ''
136
+ parts.pop()
137
+ continue
138
+ }
139
+ parts.push(part)
140
+ }
141
+ return parts.join('/')
142
+ }
143
+
144
+ /**
145
+ * Fetches and caches one model URL with rejection eviction.
146
+ * @param {unknown} model External model metadata.
147
+ * @param {object} options Model loading policy.
148
+ * @param {string} label Human-readable format label.
149
+ * @returns {Promise<Uint8Array>}
150
+ */
151
+ static async #fetchBytes(model, options, label) {
152
+ const url = String(
153
+ PcbScene3dModelContent.#data(model, 'resolvedUrl') ||
154
+ PcbScene3dModelContent.#data(model, 'sourceUrl') ||
155
+ ''
156
+ ).trim()
157
+ if (!url || !PcbScene3dModelFetchPolicy.canFetch(options)) {
158
+ throw new Error(label + ' model content is not available.')
159
+ }
160
+
161
+ const cache =
162
+ options?.modelCache instanceof Map ? options.modelCache : null
163
+ const cacheKey = 'model:' + PcbScene3dModelIdentity.resolve(model)
164
+ const cached = cache?.get(cacheKey)
165
+ if (cached) return cached
166
+
167
+ const pending = PcbScene3dModelContent.#fetchUncached(
168
+ url,
169
+ options,
170
+ label,
171
+ String(PcbScene3dModelContent.#data(model, 'mainModelUrl') || url)
172
+ )
173
+ cache?.set(cacheKey, pending)
174
+ try {
175
+ return await pending
176
+ } catch (error) {
177
+ if (cache?.get(cacheKey) === pending) cache.delete(cacheKey)
178
+ throw error
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Fetches one uncached model payload.
184
+ * @param {string} url Model URL.
185
+ * @param {object} options Fetch policy.
186
+ * @param {string} label Human-readable resource label.
187
+ * @param {string} mainUrl Main model URL for origin policy.
188
+ * @returns {Promise<Uint8Array>}
189
+ */
190
+ static async #fetchUncached(url, options, label, mainUrl) {
191
+ return PcbScene3dModelFetchPolicy.fetchBytes(url, options, {
192
+ label,
193
+ mainUrl
194
+ })
195
+ }
196
+
197
+ /**
198
+ * Converts one byte-like or blob value into bytes.
199
+ * @param {unknown} value Payload candidate.
200
+ * @returns {Promise<Uint8Array | null>}
201
+ */
202
+ static async #bytesFromValue(value) {
203
+ if (!value) return null
204
+ if (value instanceof Uint8Array) return value
205
+ if (value instanceof ArrayBuffer) return new Uint8Array(value)
206
+ if (ArrayBuffer.isView(value)) {
207
+ return new Uint8Array(
208
+ value.buffer,
209
+ value.byteOffset,
210
+ value.byteLength
211
+ )
212
+ }
213
+ if (typeof value.arrayBuffer === 'function') {
214
+ return new Uint8Array(await value.arrayBuffer())
215
+ }
216
+ return null
217
+ }
218
+
219
+ /**
220
+ * Reads one own data property without invoking accessors.
221
+ * @param {unknown} value Record candidate.
222
+ * @param {PropertyKey} key Property key.
223
+ * @returns {unknown}
224
+ */
225
+ static #data(value, key) {
226
+ if (!value || typeof value !== 'object') return undefined
227
+ try {
228
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)
229
+ return descriptor && Object.hasOwn(descriptor, 'value')
230
+ ? descriptor.value
231
+ : undefined
232
+ } catch {
233
+ return undefined
234
+ }
235
+ }
236
+ }