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.
- package/README.md +46 -21
- package/docs/api.md +109 -8
- package/docs/circuitjson.md +143 -29
- package/docs/model-format.md +50 -7
- package/docs/release-notes-v1.2.0.md +122 -0
- package/docs/testing.md +30 -1
- package/package.json +6 -3
- package/spec/library-scope.md +10 -2
- package/src/CircuitJsonCadModelAssetResolver.mjs +697 -83
- package/src/PcbAssemblyBoardSubstrateBuilder.mjs +43 -0
- package/src/PcbAssemblyGeometryBuilder.mjs +40 -18
- package/src/PcbAssemblyModelMeshLoader.mjs +101 -150
- package/src/PcbAssemblyPadMeshBuilder.mjs +22 -0
- package/src/PcbModelArchiveExporter.mjs +28 -112
- package/src/PcbModelArchiveSourceBundle.mjs +326 -0
- package/src/PcbScene3dAabbIndex.mjs +464 -0
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +1 -1
- package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +9 -5
- package/src/PcbScene3dBoardMaterialPalette.mjs +29 -0
- package/src/PcbScene3dBoardShapeFactory.mjs +11 -118
- package/src/PcbScene3dBoardSolderMaskFactory.mjs +65 -39
- package/src/PcbScene3dCircuitJsonAdapter.mjs +151 -48
- package/src/PcbScene3dCircuitJsonDrillDetail.mjs +31 -0
- package/src/PcbScene3dCircuitJsonGeometry.mjs +184 -33
- package/src/PcbScene3dCircuitJsonInput.mjs +132 -0
- package/src/PcbScene3dCircuitJsonModelAsset.mjs +40 -0
- package/src/PcbScene3dController.mjs +40 -34
- package/src/PcbScene3dCopperFactory.mjs +36 -22
- package/src/PcbScene3dCopperFillAreaClipper.mjs +133 -235
- package/src/PcbScene3dCopperFillCoverageContext.mjs +204 -0
- package/src/PcbScene3dCopperFillLoopSetResolver.mjs +192 -0
- package/src/PcbScene3dCopperFillMeshBuilder.mjs +77 -295
- package/src/PcbScene3dCopperTextFactory.mjs +12 -4
- package/src/PcbScene3dCutoutCircleDetector.mjs +34 -17
- package/src/PcbScene3dCutoutGeometryFilter.mjs +104 -269
- package/src/PcbScene3dCutoutGridIndex.mjs +184 -0
- package/src/PcbScene3dDeferredModelFinalizer.mjs +52 -0
- package/src/PcbScene3dDescriptorSafeRecord.mjs +38 -0
- package/src/PcbScene3dDrillCutoutFilter.mjs +149 -143
- package/src/PcbScene3dDrillPathFactory.mjs +86 -16
- package/src/PcbScene3dDrillVoidFactory.mjs +35 -10
- package/src/PcbScene3dExternalModelGroupLoader.mjs +472 -31
- package/src/PcbScene3dExternalModels.mjs +23 -24
- package/src/PcbScene3dFacetedModelGroupBuilder.mjs +217 -0
- package/src/PcbScene3dGeometryZCompressor.mjs +4 -2
- package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +18 -4
- package/src/PcbScene3dMaskCoveredCopperSurfaceFilter.mjs +75 -10
- package/src/PcbScene3dModelContent.mjs +236 -0
- package/src/PcbScene3dModelFetchPolicy.mjs +304 -0
- package/src/PcbScene3dModelIdentity.mjs +106 -0
- package/src/PcbScene3dPlatedDrillSpecResolver.mjs +141 -0
- package/src/PcbScene3dPreparedPolygon.mjs +709 -0
- package/src/PcbScene3dPreparedPolygonSet.mjs +70 -0
- package/src/PcbScene3dRuntime.mjs +45 -45
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +85 -1
- package/src/PcbScene3dShapeHoleGeometryCleaner.mjs +4 -2
- package/src/PcbScene3dShellRenderer.mjs +75 -6
- package/src/PcbScene3dSilkscreenCutoutContext.mjs +255 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +87 -127
- package/src/PcbScene3dSilkscreenFillSeamBuilder.mjs +11 -5
- package/src/PcbScene3dStepLoader.mjs +11 -10
- package/src/PcbScene3dText.mjs +1 -1
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
*
|
|
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 #
|
|
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
|
-
|
|
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
|
+
}
|