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.
Files changed (65) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +15 -0
  3. package/CONTRIBUTING.md +14 -0
  4. package/LICENSE +19 -0
  5. package/LICENSES/AGPL-3.0-or-later.txt +235 -0
  6. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  7. package/LICENSES/LGPL-2.1-or-later.txt +176 -0
  8. package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
  9. package/NOTICE.md +36 -0
  10. package/README.md +128 -0
  11. package/REUSE.toml +16 -0
  12. package/docs/api.md +148 -0
  13. package/docs/circuitjson.md +190 -0
  14. package/docs/model-format.md +117 -0
  15. package/docs/testing.md +23 -0
  16. package/package.json +65 -0
  17. package/spec/library-scope.md +36 -0
  18. package/src/PcbModelArchiveExporter.mjs +320 -0
  19. package/src/PcbScene3dArcUtils.mjs +27 -0
  20. package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
  21. package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
  22. package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
  23. package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
  24. package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
  25. package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
  26. package/src/PcbScene3dCameraRig.mjs +168 -0
  27. package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
  28. package/src/PcbScene3dController.mjs +956 -0
  29. package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
  30. package/src/PcbScene3dCopperFactory.mjs +559 -0
  31. package/src/PcbScene3dCopperTextFactory.mjs +534 -0
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
  33. package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
  34. package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
  35. package/src/PcbScene3dDrillPathFactory.mjs +362 -0
  36. package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
  37. package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
  38. package/src/PcbScene3dExternalModels.mjs +968 -0
  39. package/src/PcbScene3dFallbackVisibility.mjs +82 -0
  40. package/src/PcbScene3dInteractionHints.mjs +56 -0
  41. package/src/PcbScene3dMountRig.mjs +53 -0
  42. package/src/PcbScene3dOutlineBuilder.mjs +210 -0
  43. package/src/PcbScene3dPadFactory.mjs +553 -0
  44. package/src/PcbScene3dPresetState.mjs +48 -0
  45. package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
  46. package/src/PcbScene3dRuntime.mjs +996 -0
  47. package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
  48. package/src/PcbScene3dSelectionStyler.mjs +252 -0
  49. package/src/PcbScene3dShapePathFactory.mjs +220 -0
  50. package/src/PcbScene3dShellRenderer.mjs +131 -0
  51. package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
  52. package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
  53. package/src/PcbScene3dStepLoader.mjs +611 -0
  54. package/src/PcbScene3dStrokeFont.mjs +671 -0
  55. package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
  56. package/src/PcbScene3dText.mjs +99 -0
  57. package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
  58. package/src/PcbScene3dViaFactory.mjs +176 -0
  59. package/src/PcbScene3dViewCompensation.mjs +109 -0
  60. package/src/PcbScene3dViewScale.mjs +24 -0
  61. package/src/PcbScene3dViewportResize.mjs +35 -0
  62. package/src/PcbScene3dWorkerClient.mjs +123 -0
  63. package/src/index.mjs +1 -0
  64. package/src/scene3d.mjs +44 -0
  65. 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
+ }