pcb-scene3d-viewer 1.0.1
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/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +15 -0
- package/CONTRIBUTING.md +14 -0
- package/LICENSE +19 -0
- package/LICENSES/AGPL-3.0-or-later.txt +235 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/LGPL-2.1-or-later.txt +176 -0
- package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
- package/NOTICE.md +36 -0
- package/README.md +128 -0
- package/REUSE.toml +16 -0
- package/docs/api.md +148 -0
- package/docs/circuitjson.md +190 -0
- package/docs/model-format.md +117 -0
- package/docs/testing.md +23 -0
- package/package.json +65 -0
- package/spec/library-scope.md +36 -0
- package/src/PcbModelArchiveExporter.mjs +320 -0
- package/src/PcbScene3dArcUtils.mjs +27 -0
- package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
- package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
- package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
- package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
- package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
- package/src/PcbScene3dCameraRig.mjs +168 -0
- package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
- package/src/PcbScene3dController.mjs +956 -0
- package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
- package/src/PcbScene3dCopperFactory.mjs +559 -0
- package/src/PcbScene3dCopperTextFactory.mjs +534 -0
- package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
- package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
- package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
- package/src/PcbScene3dDrillPathFactory.mjs +362 -0
- package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
- package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
- package/src/PcbScene3dExternalModels.mjs +968 -0
- package/src/PcbScene3dFallbackVisibility.mjs +82 -0
- package/src/PcbScene3dInteractionHints.mjs +56 -0
- package/src/PcbScene3dMountRig.mjs +53 -0
- package/src/PcbScene3dOutlineBuilder.mjs +210 -0
- package/src/PcbScene3dPadFactory.mjs +553 -0
- package/src/PcbScene3dPresetState.mjs +48 -0
- package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
- package/src/PcbScene3dRuntime.mjs +996 -0
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
- package/src/PcbScene3dSelectionStyler.mjs +252 -0
- package/src/PcbScene3dShapePathFactory.mjs +220 -0
- package/src/PcbScene3dShellRenderer.mjs +131 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
- package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
- package/src/PcbScene3dStepLoader.mjs +611 -0
- package/src/PcbScene3dStrokeFont.mjs +671 -0
- package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
- package/src/PcbScene3dText.mjs +99 -0
- package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
- package/src/PcbScene3dViaFactory.mjs +176 -0
- package/src/PcbScene3dViewCompensation.mjs +109 -0
- package/src/PcbScene3dViewScale.mjs +24 -0
- package/src/PcbScene3dViewportResize.mjs +35 -0
- package/src/PcbScene3dWorkerClient.mjs +123 -0
- package/src/index.mjs +1 -0
- package/src/scene3d.mjs +44 -0
- package/src/styles/scene3d.css +295 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves 3D silkscreen stroke widths without changing sparse linework.
|
|
3
|
+
*/
|
|
4
|
+
export class PcbScene3dSilkscreenStrokeWidthResolver {
|
|
5
|
+
static #DENSE_HAIRLINE_COUNT = 128
|
|
6
|
+
static #HAIRLINE_MAX_WIDTH_MIL = 1
|
|
7
|
+
static #DENSE_HAIRLINE_EXTRA_WIDTH_MIL = 0.63
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Finds the repeated hairline width used by dense raster-style artwork.
|
|
11
|
+
* @param {{ width?: number }[]} tracks
|
|
12
|
+
* @returns {{ widthKey: string, renderWidth: number } | null}
|
|
13
|
+
*/
|
|
14
|
+
static resolveDenseHairline(tracks) {
|
|
15
|
+
const counts = new Map()
|
|
16
|
+
|
|
17
|
+
for (const track of Array.isArray(tracks) ? tracks : []) {
|
|
18
|
+
const width = Number(track?.width || 0)
|
|
19
|
+
if (
|
|
20
|
+
!Number.isFinite(width) ||
|
|
21
|
+
width <= 0 ||
|
|
22
|
+
width >
|
|
23
|
+
PcbScene3dSilkscreenStrokeWidthResolver
|
|
24
|
+
.#HAIRLINE_MAX_WIDTH_MIL
|
|
25
|
+
) {
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const widthKey =
|
|
30
|
+
PcbScene3dSilkscreenStrokeWidthResolver.#buildWidthKey(width)
|
|
31
|
+
counts.set(widthKey, (counts.get(widthKey) || 0) + 1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const denseWidth = [...counts.entries()].find(
|
|
35
|
+
([, count]) =>
|
|
36
|
+
count >=
|
|
37
|
+
PcbScene3dSilkscreenStrokeWidthResolver.#DENSE_HAIRLINE_COUNT
|
|
38
|
+
)
|
|
39
|
+
if (!denseWidth) {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const width = Number(denseWidth[0])
|
|
44
|
+
return {
|
|
45
|
+
widthKey: denseWidth[0],
|
|
46
|
+
renderWidth:
|
|
47
|
+
width +
|
|
48
|
+
PcbScene3dSilkscreenStrokeWidthResolver
|
|
49
|
+
.#DENSE_HAIRLINE_EXTRA_WIDTH_MIL
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolves the rendered width for one track.
|
|
55
|
+
* @param {number} width
|
|
56
|
+
* @param {{ widthKey: string, renderWidth: number } | null} denseHairline
|
|
57
|
+
* @returns {number}
|
|
58
|
+
*/
|
|
59
|
+
static resolveTrackWidth(width, denseHairline) {
|
|
60
|
+
const numericWidth = Number(width || 0)
|
|
61
|
+
if (
|
|
62
|
+
!denseHairline ||
|
|
63
|
+
PcbScene3dSilkscreenStrokeWidthResolver.#buildWidthKey(
|
|
64
|
+
numericWidth
|
|
65
|
+
) !== denseHairline.widthKey
|
|
66
|
+
) {
|
|
67
|
+
return numericWidth
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return Math.max(numericWidth, denseHairline.renderWidth)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds a stable key for comparing near-identical parsed widths.
|
|
75
|
+
* @param {number} width
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
static #buildWidthKey(width) {
|
|
79
|
+
return Number(width || 0).toFixed(2)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side STEP mesh loader backed by occt-import-js.
|
|
3
|
+
*/
|
|
4
|
+
export class PcbScene3dStepLoader {
|
|
5
|
+
/** @type {(() => Promise<{ ReadStepFile?: (content: Uint8Array, params: Record<string, any> | null) => any }>) | null} */
|
|
6
|
+
static #browserImporterLoader
|
|
7
|
+
|
|
8
|
+
/** @type {Promise<{ ReadStepFile?: (content: Uint8Array, params: Record<string, any> | null) => any }> | null} */
|
|
9
|
+
static #browserImporterPromise
|
|
10
|
+
|
|
11
|
+
/** @type {Promise<void> | null} */
|
|
12
|
+
static #browserScriptPromise
|
|
13
|
+
|
|
14
|
+
/** @type {() => Promise<{ ReadStepFile?: (content: Uint8Array, params: Record<string, any> | null) => any }>} */
|
|
15
|
+
#importerLoader
|
|
16
|
+
|
|
17
|
+
/** @type {(() => Worker) | null} */
|
|
18
|
+
#stepWorkerFactory
|
|
19
|
+
|
|
20
|
+
/** @type {Map<string, Promise<{ meshPayloads: { name: string, color: number[] | null, positions: number[], normals: number[], indices: number[], faceColors: { first: number, last: number, color: number[] | null }[] }[] }>>} */
|
|
21
|
+
#cache
|
|
22
|
+
|
|
23
|
+
/** @type {Worker | null} */
|
|
24
|
+
#stepWorker
|
|
25
|
+
|
|
26
|
+
/** @type {{ resolve: (result: any) => void, reject: (error: Error) => void } | null} */
|
|
27
|
+
#activeWorkerRequest
|
|
28
|
+
|
|
29
|
+
/** @type {Promise<void>} */
|
|
30
|
+
#workerRequestChain
|
|
31
|
+
|
|
32
|
+
/** @type {(event: any) => void} */
|
|
33
|
+
#boundWorkerMessage
|
|
34
|
+
|
|
35
|
+
/** @type {(event: any) => void} */
|
|
36
|
+
#boundWorkerError
|
|
37
|
+
|
|
38
|
+
/** @type {boolean} */
|
|
39
|
+
#isDisposed
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {{ importerLoader?: () => Promise<{ ReadStepFile?: (content: Uint8Array, params: Record<string, any> | null) => any }>, stepWorkerFactory?: (() => Worker) | null }} [options]
|
|
43
|
+
*/
|
|
44
|
+
constructor(options = {}) {
|
|
45
|
+
this.#importerLoader =
|
|
46
|
+
options.importerLoader ||
|
|
47
|
+
(() => PcbScene3dStepLoader.#loadBrowserImporter())
|
|
48
|
+
this.#stepWorkerFactory = options.stepWorkerFactory || null
|
|
49
|
+
this.#cache = new Map()
|
|
50
|
+
this.#stepWorker = null
|
|
51
|
+
this.#activeWorkerRequest = null
|
|
52
|
+
this.#workerRequestChain = Promise.resolve()
|
|
53
|
+
this.#boundWorkerMessage = (event) => this.#handleWorkerMessage(event)
|
|
54
|
+
this.#boundWorkerError = (event) => this.#handleWorkerError(event)
|
|
55
|
+
this.#isDisposed = false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Releases any persistent STEP worker owned by this loader.
|
|
60
|
+
* @returns {void}
|
|
61
|
+
*/
|
|
62
|
+
dispose() {
|
|
63
|
+
if (this.#isDisposed) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.#isDisposed = true
|
|
68
|
+
this.#rejectActiveWorkerRequest(
|
|
69
|
+
new Error('STEP importer worker terminated.')
|
|
70
|
+
)
|
|
71
|
+
this.#disposeWorker()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Loads one STEP model and normalizes it into triangle payloads.
|
|
76
|
+
* @param {{ origin?: string, name?: string, sourceStream?: string, relativePath?: string, payloadText?: string, file?: Blob | File | null }} model
|
|
77
|
+
* @returns {Promise<{ meshPayloads: { name: string, color: number[] | null, positions: number[], normals: number[], indices: number[], faceColors: { first: number, last: number, color: number[] | null }[] }[] }>}
|
|
78
|
+
*/
|
|
79
|
+
async loadModel(model) {
|
|
80
|
+
const cacheKey = PcbScene3dStepLoader.#resolveCacheKey(model)
|
|
81
|
+
const cachedLoad = this.#cache.get(cacheKey)
|
|
82
|
+
if (cachedLoad) {
|
|
83
|
+
return await cachedLoad
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const pendingLoad = this.#loadModelUncached(model)
|
|
87
|
+
this.#cache.set(cacheKey, pendingLoad)
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
return await pendingLoad
|
|
91
|
+
} catch (error) {
|
|
92
|
+
this.#cache.delete(cacheKey)
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Loads one uncached STEP model.
|
|
99
|
+
* @param {{ origin?: string, name?: string, sourceStream?: string, relativePath?: string, payloadText?: string, file?: Blob | File | null }} model
|
|
100
|
+
* @returns {Promise<{ meshPayloads: { name: string, color: number[] | null, positions: number[], normals: number[], indices: number[], faceColors: { first: number, last: number, color: number[] | null }[] }[] }>}
|
|
101
|
+
*/
|
|
102
|
+
async #loadModelUncached(model) {
|
|
103
|
+
const modelName = PcbScene3dStepLoader.#resolveModelName(model)
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const content = await PcbScene3dStepLoader.#readModelContent(model)
|
|
107
|
+
const result = await this.#readImporterResult(content)
|
|
108
|
+
|
|
109
|
+
const meshPayloads = Array.isArray(result?.meshes)
|
|
110
|
+
? result.meshes
|
|
111
|
+
.map((mesh) =>
|
|
112
|
+
PcbScene3dStepLoader.#normalizeMeshPayload(mesh)
|
|
113
|
+
)
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
: []
|
|
116
|
+
|
|
117
|
+
if (!result?.success || !meshPayloads.length) {
|
|
118
|
+
throw new Error('Importer returned no mesh payloads.')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
meshPayloads: PcbScene3dStepLoader.#normalizeModelOrigin(
|
|
123
|
+
meshPayloads,
|
|
124
|
+
model
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
'STEP import failed for ' +
|
|
130
|
+
modelName +
|
|
131
|
+
': ' +
|
|
132
|
+
String(error?.message || error || 'Unknown error.')
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Reads one importer result either through the page importer or a nested
|
|
139
|
+
* worker-backed importer.
|
|
140
|
+
* @param {Uint8Array} content
|
|
141
|
+
* @returns {Promise<any>}
|
|
142
|
+
*/
|
|
143
|
+
async #readImporterResult(content) {
|
|
144
|
+
if (this.#stepWorkerFactory) {
|
|
145
|
+
return await this.#readImporterResultWithWorker(content)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const importer = await this.#importerLoader()
|
|
149
|
+
if (typeof importer?.ReadStepFile !== 'function') {
|
|
150
|
+
throw new Error('STEP importer is unavailable.')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return importer.ReadStepFile(content, {
|
|
154
|
+
linearUnit: 'inch',
|
|
155
|
+
linearDeflectionType: 'bounding_box_ratio',
|
|
156
|
+
linearDeflection: 0.0015,
|
|
157
|
+
angularDeflection: 0.35
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Reads one importer result through a short-lived worker-backed importer.
|
|
163
|
+
* @param {Uint8Array} content
|
|
164
|
+
* @returns {Promise<any>}
|
|
165
|
+
*/
|
|
166
|
+
async #readImporterResultWithWorker(content) {
|
|
167
|
+
const pendingResult = this.#workerRequestChain.then(() =>
|
|
168
|
+
this.#readImporterResultWithPersistentWorker(content)
|
|
169
|
+
)
|
|
170
|
+
this.#workerRequestChain = pendingResult.then(
|
|
171
|
+
() => undefined,
|
|
172
|
+
() => undefined
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return await pendingResult
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reads one importer result through the loader-owned persistent worker.
|
|
180
|
+
* Requests are serialized because the vendored worker does not echo a
|
|
181
|
+
* request id back in its response payload.
|
|
182
|
+
* @param {Uint8Array} content
|
|
183
|
+
* @returns {Promise<any>}
|
|
184
|
+
*/
|
|
185
|
+
async #readImporterResultWithPersistentWorker(content) {
|
|
186
|
+
const stepWorker = this.#ensurePersistentWorker()
|
|
187
|
+
|
|
188
|
+
return await new Promise((resolve, reject) => {
|
|
189
|
+
this.#activeWorkerRequest = { resolve, reject }
|
|
190
|
+
stepWorker.postMessage(
|
|
191
|
+
{
|
|
192
|
+
format: 'step',
|
|
193
|
+
buffer: content,
|
|
194
|
+
params: {
|
|
195
|
+
linearUnit: 'inch',
|
|
196
|
+
linearDeflectionType: 'bounding_box_ratio',
|
|
197
|
+
linearDeflection: 0.0015,
|
|
198
|
+
angularDeflection: 0.35
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
[content.buffer]
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Creates the persistent STEP worker once and reuses it across imports.
|
|
208
|
+
* @returns {Worker}
|
|
209
|
+
*/
|
|
210
|
+
#ensurePersistentWorker() {
|
|
211
|
+
if (this.#isDisposed) {
|
|
212
|
+
throw new Error('STEP importer loader has been disposed.')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (this.#stepWorker) {
|
|
216
|
+
return this.#stepWorker
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const stepWorker = this.#stepWorkerFactory?.()
|
|
220
|
+
if (!stepWorker) {
|
|
221
|
+
throw new Error('STEP importer worker is unavailable.')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
stepWorker.addEventListener?.('message', this.#boundWorkerMessage)
|
|
225
|
+
stepWorker.addEventListener?.('error', this.#boundWorkerError)
|
|
226
|
+
this.#stepWorker = stepWorker
|
|
227
|
+
|
|
228
|
+
return stepWorker
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Routes the active worker response back to the pending import promise.
|
|
233
|
+
* @param {{ data?: any }} event
|
|
234
|
+
* @returns {void}
|
|
235
|
+
*/
|
|
236
|
+
#handleWorkerMessage(event) {
|
|
237
|
+
const activeRequest = this.#activeWorkerRequest
|
|
238
|
+
this.#activeWorkerRequest = null
|
|
239
|
+
activeRequest?.resolve(event?.data || {})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Rejects the active worker request and resets the worker after an error.
|
|
244
|
+
* @param {{ message?: string }} event
|
|
245
|
+
* @returns {void}
|
|
246
|
+
*/
|
|
247
|
+
#handleWorkerError(event) {
|
|
248
|
+
const error = new Error(
|
|
249
|
+
String(event?.message || 'STEP importer worker failed.')
|
|
250
|
+
)
|
|
251
|
+
this.#rejectActiveWorkerRequest(error)
|
|
252
|
+
this.#disposeWorker()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Rejects the active worker request if one is still pending.
|
|
257
|
+
* @param {Error} error
|
|
258
|
+
* @returns {void}
|
|
259
|
+
*/
|
|
260
|
+
#rejectActiveWorkerRequest(error) {
|
|
261
|
+
const activeRequest = this.#activeWorkerRequest
|
|
262
|
+
this.#activeWorkerRequest = null
|
|
263
|
+
activeRequest?.reject(error)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Terminates the persistent worker and clears its event listeners.
|
|
268
|
+
* @returns {void}
|
|
269
|
+
*/
|
|
270
|
+
#disposeWorker() {
|
|
271
|
+
if (!this.#stepWorker) {
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
this.#stepWorker.removeEventListener?.(
|
|
276
|
+
'message',
|
|
277
|
+
this.#boundWorkerMessage
|
|
278
|
+
)
|
|
279
|
+
this.#stepWorker.removeEventListener?.('error', this.#boundWorkerError)
|
|
280
|
+
this.#stepWorker.terminate?.()
|
|
281
|
+
this.#stepWorker = null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Resolves one model identity to a stable cache key.
|
|
286
|
+
* @param {{ origin?: string, name?: string, sourceStream?: string, relativePath?: string }} model
|
|
287
|
+
* @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
static #resolveCacheKey(model) {
|
|
290
|
+
return [
|
|
291
|
+
String(model?.origin || 'unknown'),
|
|
292
|
+
String(model?.sourceStream || ''),
|
|
293
|
+
String(model?.relativePath || ''),
|
|
294
|
+
String(model?.name || '')
|
|
295
|
+
].join('|')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Resolves one human-readable model name.
|
|
300
|
+
* @param {{ name?: string, relativePath?: string, sourceStream?: string }} model
|
|
301
|
+
* @returns {string}
|
|
302
|
+
*/
|
|
303
|
+
static #resolveModelName(model) {
|
|
304
|
+
return String(
|
|
305
|
+
model?.name || model?.relativePath || model?.sourceStream || 'model'
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Reads one STEP model into bytes from embedded text or a session file.
|
|
311
|
+
* @param {{ payloadText?: string, file?: Blob | File | null }} model
|
|
312
|
+
* @returns {Promise<Uint8Array>}
|
|
313
|
+
*/
|
|
314
|
+
static async #readModelContent(model) {
|
|
315
|
+
const payloadText =
|
|
316
|
+
typeof model?.payloadText === 'string' ? model.payloadText : ''
|
|
317
|
+
if (payloadText) {
|
|
318
|
+
return new TextEncoder().encode(payloadText)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (typeof model?.file?.arrayBuffer === 'function') {
|
|
322
|
+
return new Uint8Array(await model.file.arrayBuffer())
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
throw new Error('STEP model content is not available.')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Normalizes one importer mesh into serializable numeric arrays.
|
|
330
|
+
* @param {any} mesh
|
|
331
|
+
* @returns {{ name: string, color: number[] | null, positions: number[], normals: number[], indices: number[], faceColors: { first: number, last: number, color: number[] | null }[] } | null}
|
|
332
|
+
*/
|
|
333
|
+
static #normalizeMeshPayload(mesh) {
|
|
334
|
+
const positions = Array.from(mesh?.attributes?.position?.array || [])
|
|
335
|
+
const indices = Array.from(mesh?.index?.array || [])
|
|
336
|
+
if (!positions.length || !indices.length) {
|
|
337
|
+
return null
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const color =
|
|
341
|
+
Array.isArray(mesh?.color) && mesh.color.length >= 3
|
|
342
|
+
? mesh.color.slice(0, 3).map((channel) => Number(channel || 0))
|
|
343
|
+
: null
|
|
344
|
+
const faceColors = Array.isArray(mesh?.brep_faces)
|
|
345
|
+
? mesh.brep_faces
|
|
346
|
+
.map((faceColor) =>
|
|
347
|
+
PcbScene3dStepLoader.#normalizeFaceColorRange(faceColor)
|
|
348
|
+
)
|
|
349
|
+
.filter(Boolean)
|
|
350
|
+
: []
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
name: String(mesh?.name || ''),
|
|
354
|
+
color,
|
|
355
|
+
positions: positions.map((value) => Number(value || 0)),
|
|
356
|
+
normals: Array.from(mesh?.attributes?.normal?.array || []).map(
|
|
357
|
+
(value) => Number(value || 0)
|
|
358
|
+
),
|
|
359
|
+
indices: indices.map((value) => Number(value || 0)),
|
|
360
|
+
faceColors
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Normalizes one STEP face-color range into serializable numeric values.
|
|
366
|
+
* @param {any} faceColor
|
|
367
|
+
* @returns {{ first: number, last: number, color: number[] | null } | null}
|
|
368
|
+
*/
|
|
369
|
+
static #normalizeFaceColorRange(faceColor) {
|
|
370
|
+
const first = Number(faceColor?.first)
|
|
371
|
+
const last = Number(faceColor?.last)
|
|
372
|
+
|
|
373
|
+
if (
|
|
374
|
+
!Number.isInteger(first) ||
|
|
375
|
+
!Number.isInteger(last) ||
|
|
376
|
+
last < first
|
|
377
|
+
) {
|
|
378
|
+
return null
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
first,
|
|
383
|
+
last,
|
|
384
|
+
color:
|
|
385
|
+
Array.isArray(faceColor?.color) && faceColor.color.length >= 3
|
|
386
|
+
? faceColor.color
|
|
387
|
+
.slice(0, 3)
|
|
388
|
+
.map((channel) => Number(channel || 0))
|
|
389
|
+
: null
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Re-centers STEP payloads whose XY coordinates are obviously stored far
|
|
395
|
+
* away from the local origin, which happens for some embedded Altium
|
|
396
|
+
* models that retain absolute CAD world offsets.
|
|
397
|
+
* @param {{ name: string, color: number[] | null, positions: number[], normals: number[], indices: number[], faceColors: { first: number, last: number, color: number[] | null }[] }[]} meshPayloads
|
|
398
|
+
* @param {{ origin?: string }} model
|
|
399
|
+
* @returns {{ name: string, color: number[] | null, positions: number[], normals: number[], indices: number[], faceColors: { first: number, last: number, color: number[] | null }[] }[]}
|
|
400
|
+
*/
|
|
401
|
+
static #normalizeModelOrigin(meshPayloads, model) {
|
|
402
|
+
if (!PcbScene3dStepLoader.#shouldNormalizeModelOrigin(model)) {
|
|
403
|
+
return meshPayloads
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const bounds = PcbScene3dStepLoader.#measureModelBounds(meshPayloads)
|
|
407
|
+
if (!bounds) {
|
|
408
|
+
return meshPayloads
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const maxDimension = Math.max(
|
|
412
|
+
bounds.maxX - bounds.minX,
|
|
413
|
+
bounds.maxY - bounds.minY,
|
|
414
|
+
bounds.maxZ - bounds.minZ,
|
|
415
|
+
0.000001
|
|
416
|
+
)
|
|
417
|
+
const centerX = (bounds.minX + bounds.maxX) / 2
|
|
418
|
+
const centerY = (bounds.minY + bounds.maxY) / 2
|
|
419
|
+
const shouldNormalize =
|
|
420
|
+
Math.max(Math.abs(centerX), Math.abs(centerY)) > maxDimension * 1.5
|
|
421
|
+
|
|
422
|
+
if (!shouldNormalize) {
|
|
423
|
+
return meshPayloads
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return meshPayloads.map((meshPayload) => ({
|
|
427
|
+
...meshPayload,
|
|
428
|
+
positions: meshPayload.positions.map((value, index) => {
|
|
429
|
+
if (index % 3 === 0) {
|
|
430
|
+
return value - centerX
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (index % 3 === 1) {
|
|
434
|
+
return value - centerY
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return value
|
|
438
|
+
})
|
|
439
|
+
}))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Checks whether imported STEP coordinates should be recentered.
|
|
444
|
+
* Project-local KiCad models keep their authored origin because KiCad
|
|
445
|
+
* applies model offsets and rotations against that raw model frame.
|
|
446
|
+
* @param {{ origin?: string }} model
|
|
447
|
+
* @returns {boolean}
|
|
448
|
+
*/
|
|
449
|
+
static #shouldNormalizeModelOrigin(model) {
|
|
450
|
+
return String(model?.origin || '').toLowerCase() === 'embedded'
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Measures the combined bounds across all normalized STEP meshes.
|
|
455
|
+
* @param {{ positions: number[] }[]} meshPayloads
|
|
456
|
+
* @returns {{ minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number } | null}
|
|
457
|
+
*/
|
|
458
|
+
static #measureModelBounds(meshPayloads) {
|
|
459
|
+
let minX = Number.POSITIVE_INFINITY
|
|
460
|
+
let minY = Number.POSITIVE_INFINITY
|
|
461
|
+
let minZ = Number.POSITIVE_INFINITY
|
|
462
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
463
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
464
|
+
let maxZ = Number.NEGATIVE_INFINITY
|
|
465
|
+
|
|
466
|
+
meshPayloads.forEach((meshPayload) => {
|
|
467
|
+
meshPayload.positions.forEach((value, index) => {
|
|
468
|
+
const numericValue = Number(value || 0)
|
|
469
|
+
|
|
470
|
+
if (index % 3 === 0) {
|
|
471
|
+
minX = Math.min(minX, numericValue)
|
|
472
|
+
maxX = Math.max(maxX, numericValue)
|
|
473
|
+
} else if (index % 3 === 1) {
|
|
474
|
+
minY = Math.min(minY, numericValue)
|
|
475
|
+
maxY = Math.max(maxY, numericValue)
|
|
476
|
+
} else {
|
|
477
|
+
minZ = Math.min(minZ, numericValue)
|
|
478
|
+
maxZ = Math.max(maxZ, numericValue)
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
if (
|
|
484
|
+
!Number.isFinite(minX) ||
|
|
485
|
+
!Number.isFinite(minY) ||
|
|
486
|
+
!Number.isFinite(minZ) ||
|
|
487
|
+
!Number.isFinite(maxX) ||
|
|
488
|
+
!Number.isFinite(maxY) ||
|
|
489
|
+
!Number.isFinite(maxZ)
|
|
490
|
+
) {
|
|
491
|
+
return null
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
minX,
|
|
496
|
+
minY,
|
|
497
|
+
minZ,
|
|
498
|
+
maxX,
|
|
499
|
+
maxY,
|
|
500
|
+
maxZ
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Loads the browser OCCT importer instance once per page.
|
|
506
|
+
* @returns {Promise<{ ReadStepFile?: (content: Uint8Array, params: Record<string, any> | null) => any }>}
|
|
507
|
+
*/
|
|
508
|
+
static async #loadBrowserImporter() {
|
|
509
|
+
if (!PcbScene3dStepLoader.#browserImporterLoader) {
|
|
510
|
+
PcbScene3dStepLoader.#browserImporterLoader = async () => {
|
|
511
|
+
if (
|
|
512
|
+
typeof window === 'undefined' ||
|
|
513
|
+
typeof document === 'undefined'
|
|
514
|
+
) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
'Browser STEP importer requires window and document.'
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
await PcbScene3dStepLoader.#ensureBrowserScript()
|
|
521
|
+
|
|
522
|
+
const factory = globalThis.occtimportjs
|
|
523
|
+
if (typeof factory !== 'function') {
|
|
524
|
+
throw new Error(
|
|
525
|
+
'occt-import-js did not register a browser factory.'
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return await factory({
|
|
530
|
+
locateFile: (fileName) =>
|
|
531
|
+
PcbScene3dStepLoader.#resolveVendorAssetUrl(fileName)
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (!PcbScene3dStepLoader.#browserImporterPromise) {
|
|
537
|
+
PcbScene3dStepLoader.#browserImporterPromise =
|
|
538
|
+
PcbScene3dStepLoader.#browserImporterLoader()
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return await PcbScene3dStepLoader.#browserImporterPromise
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Ensures the browser importer script has been loaded once.
|
|
546
|
+
* @returns {Promise<void>}
|
|
547
|
+
*/
|
|
548
|
+
static async #ensureBrowserScript() {
|
|
549
|
+
if (PcbScene3dStepLoader.#browserScriptPromise) {
|
|
550
|
+
return await PcbScene3dStepLoader.#browserScriptPromise
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
PcbScene3dStepLoader.#browserScriptPromise = new Promise(
|
|
554
|
+
(resolve, reject) => {
|
|
555
|
+
const existingScript = document.querySelector(
|
|
556
|
+
'script[data-occt-import-js]'
|
|
557
|
+
)
|
|
558
|
+
if (existingScript) {
|
|
559
|
+
existingScript.addEventListener('load', () => resolve(), {
|
|
560
|
+
once: true
|
|
561
|
+
})
|
|
562
|
+
existingScript.addEventListener(
|
|
563
|
+
'error',
|
|
564
|
+
() =>
|
|
565
|
+
reject(
|
|
566
|
+
new Error(
|
|
567
|
+
'STEP importer script failed to load.'
|
|
568
|
+
)
|
|
569
|
+
),
|
|
570
|
+
{ once: true }
|
|
571
|
+
)
|
|
572
|
+
if (typeof globalThis.occtimportjs === 'function') {
|
|
573
|
+
resolve()
|
|
574
|
+
}
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const script = document.createElement('script')
|
|
579
|
+
script.async = true
|
|
580
|
+
script.dataset.occtImportJs = 'true'
|
|
581
|
+
script.src =
|
|
582
|
+
PcbScene3dStepLoader.#resolveVendorAssetUrl(
|
|
583
|
+
'occt-import-js.js'
|
|
584
|
+
)
|
|
585
|
+
script.addEventListener('load', () => resolve(), { once: true })
|
|
586
|
+
script.addEventListener(
|
|
587
|
+
'error',
|
|
588
|
+
() =>
|
|
589
|
+
reject(
|
|
590
|
+
new Error('STEP importer script failed to load.')
|
|
591
|
+
),
|
|
592
|
+
{ once: true }
|
|
593
|
+
)
|
|
594
|
+
document.head.append(script)
|
|
595
|
+
}
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
return await PcbScene3dStepLoader.#browserScriptPromise
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Resolves one vendored importer asset URL with the current app version.
|
|
603
|
+
* @param {string} fileName
|
|
604
|
+
* @returns {string}
|
|
605
|
+
*/
|
|
606
|
+
static #resolveVendorAssetUrl(fileName) {
|
|
607
|
+
const versionKey = new URL(import.meta.url).searchParams.get('v') || ''
|
|
608
|
+
const suffix = versionKey ? '?v=' + encodeURIComponent(versionKey) : ''
|
|
609
|
+
return '/vendor/occt-import-js/dist/' + String(fileName || '') + suffix
|
|
610
|
+
}
|
|
611
|
+
}
|