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,956 @@
1
+ import { PcbModelArchiveExporter } from './PcbModelArchiveExporter.mjs'
2
+ import { PcbScene3dCircuitJsonAdapter } from './PcbScene3dCircuitJsonAdapter.mjs'
3
+ import { PcbScene3dInteractionHints } from './PcbScene3dInteractionHints.mjs'
4
+ import { PcbScene3dRuntime } from './PcbScene3dRuntime.mjs'
5
+ import { PcbScene3dText } from './PcbScene3dText.mjs'
6
+
7
+ /**
8
+ * Wires the 3D scene shell to a runtime implementation.
9
+ */
10
+ export class PcbScene3dController {
11
+ /** @type {HTMLElement | null} */
12
+ #viewportNode
13
+
14
+ /** @type {any} */
15
+ #documentModel
16
+
17
+ /** @type {HTMLElement | null} */
18
+ #rootNode
19
+
20
+ /** @type {HTMLElement | null} */
21
+ #diagnosticsNode
22
+
23
+ /** @type {HTMLElement | null} */
24
+ #selectionNode
25
+
26
+ /** @type {Array<{ node: EventTarget, type: string, listener: (event: any) => void }>} */
27
+ #listeners
28
+
29
+ /** @type {Map<string, { component: any | null, externalPlacement: any | null }>} */
30
+ #selectionIndex
31
+
32
+ /** @type {string} */
33
+ #selectedComponentKey
34
+
35
+ /** @type {{ setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, dispose?: () => void } | null} */
36
+ #runtime
37
+
38
+ /** @type {any | null} */
39
+ #sceneDescription
40
+
41
+ /** @type {{ prepareScene?: (documentModel: any, sessionAssets?: any[]) => Promise<any>, dispose?: () => void } | null} */
42
+ #scenePrepClient
43
+
44
+ /** @type {(options: { archiveBaseName?: string, sceneDescription?: any }) => Promise<{ archiveName: string, archiveBytes: Uint8Array, exportedEntries: any[], skippedEntries: any[] }>} */
45
+ #exportArchive
46
+
47
+ /** @type {(archiveName: string, archiveBytes: Uint8Array) => Promise<void> | void} */
48
+ #downloadArchive
49
+
50
+ /** @type {(visible: boolean) => void} */
51
+ #setLoadingVisible
52
+
53
+ /** @type {(key: string) => string} */
54
+ #translate
55
+
56
+ /** @type {string} */
57
+ #documentId
58
+
59
+ /** @type {((change: { documentId: string, componentKey: string, source?: string }) => void) | null} */
60
+ #onComponentSelectionChange
61
+
62
+ /** @type {boolean} */
63
+ #isDisposed
64
+
65
+ /**
66
+ * @param {HTMLElement} viewportNode
67
+ * @param {any} documentModel
68
+ * @param {{ rootNode?: HTMLElement | null, documentId?: string, onComponentSelectionChange?: ((change: { documentId: string, componentKey: string, source?: string }) => void) | null, sessionAssets?: any[], circuitJson?: object[], sceneDescription?: any, buildScene?: (documentModel: any, options: { modelRegistry: any }) => any, createModelRegistry?: (documentModel: any, sessionAssets: any[]) => any, createRuntime?: (viewportNode: HTMLElement, sceneDescription: any, hooks: { setDiagnostics: (messages: string[]) => void, setSelection: (selection: any | null) => void, translate?: ((key: string) => string) | null }) => { setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, dispose?: () => void, whenReady?: () => Promise<void> | void }, scenePrepClient?: { prepareScene?: (documentModel: any, sessionAssets?: any[]) => Promise<any>, dispose?: () => void } | null, exportArchive?: (options: { archiveBaseName?: string, sceneDescription?: any }) => Promise<{ archiveName: string, archiveBytes: Uint8Array, exportedEntries: any[], skippedEntries: any[] }>, downloadArchive?: (archiveName: string, archiveBytes: Uint8Array) => Promise<void> | void, setLoadingVisible?: (visible: boolean) => void, translate?: ((key: string) => string) | null }} [options]
69
+ */
70
+ constructor(viewportNode, documentModel, options = {}) {
71
+ this.#viewportNode = viewportNode
72
+ this.#documentModel = documentModel
73
+ this.#rootNode =
74
+ options.rootNode ||
75
+ (typeof viewportNode.closest === 'function'
76
+ ? viewportNode.closest('.scene-3d')
77
+ : null)
78
+ this.#diagnosticsNode = this.#rootNode?.querySelector(
79
+ '.scene-3d__diagnostics'
80
+ )
81
+ this.#selectionNode = this.#rootNode?.querySelector(
82
+ '.scene-3d__selection'
83
+ )
84
+ this.#listeners = []
85
+ this.#scenePrepClient = options.scenePrepClient || null
86
+ this.#sceneDescription = null
87
+ this.#exportArchive =
88
+ options.exportArchive ||
89
+ ((exportOptions) =>
90
+ PcbModelArchiveExporter.buildArchive(exportOptions))
91
+ this.#downloadArchive =
92
+ options.downloadArchive ||
93
+ ((archiveName, archiveBytes) =>
94
+ PcbScene3dController.#triggerArchiveDownload(
95
+ archiveName,
96
+ archiveBytes
97
+ ))
98
+ this.#setLoadingVisible = options.setLoadingVisible || (() => {})
99
+ this.#translate = PcbScene3dText.createTranslator(
100
+ options.translate || null
101
+ )
102
+ this.#documentId = String(options.documentId || '')
103
+ this.#onComponentSelectionChange =
104
+ typeof options.onComponentSelectionChange === 'function'
105
+ ? options.onComponentSelectionChange
106
+ : null
107
+ this.#isDisposed = false
108
+ this.#runtime = null
109
+ this.#selectionIndex = new Map()
110
+ this.#selectedComponentKey = ''
111
+
112
+ this.#bindPresets()
113
+ this.#setActivePresetButton('isometric')
114
+ this.#bindToggles()
115
+ this.#bindExportAction()
116
+ this.#setSelection(null)
117
+ this.#setLoadingVisible(true)
118
+ const circuitJsonModel = PcbScene3dController.#resolveCircuitJsonModel(
119
+ options,
120
+ this.#documentModel
121
+ )
122
+ if (circuitJsonModel) {
123
+ this.#initializePreparedScene(circuitJsonModel, options)
124
+ return
125
+ }
126
+
127
+ if (options.sceneDescription) {
128
+ this.#initializePreparedScene(options.sceneDescription, options)
129
+ return
130
+ }
131
+
132
+ if (this.#scenePrepClient?.prepareScene) {
133
+ this.#initializeScene(options)
134
+ return
135
+ }
136
+
137
+ this.#initializeSceneSync(options)
138
+ }
139
+
140
+ /**
141
+ * Mounts an already-prepared scene description.
142
+ * @param {any} sceneDescription Prepared scene description.
143
+ * @param {{ createRuntime?: (viewportNode: HTMLElement, sceneDescription: any, hooks: { setDiagnostics: (messages: string[]) => void, setSelection: (selection: any | null) => void }) => { setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, dispose?: () => void, whenReady?: () => Promise<void> | void } }} options Controller options.
144
+ * @returns {void}
145
+ */
146
+ #initializePreparedScene(sceneDescription, options) {
147
+ try {
148
+ this.#mountScene(sceneDescription, options)
149
+ Promise.resolve(this.#runtime?.whenReady?.()).finally(() => {
150
+ if (this.#isDisposed) {
151
+ return
152
+ }
153
+
154
+ this.#setLoadingVisible(false)
155
+ })
156
+ } catch (error) {
157
+ this.#setDiagnostics([
158
+ this.#translate('scene3d.startFailed') +
159
+ ' ' +
160
+ String(error?.message || error || 'Unknown error.')
161
+ ])
162
+ this.#setLoadingVisible(false)
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Returns the mounted document model.
168
+ * @returns {any}
169
+ */
170
+ getDocumentModel() {
171
+ return this.#documentModel
172
+ }
173
+
174
+ /**
175
+ * Updates the selected component on the mounted runtime.
176
+ * @param {string} componentKey Selected component key.
177
+ * @returns {void}
178
+ */
179
+ setSelectedComponent(componentKey) {
180
+ const designator = String(componentKey || '').trim()
181
+ this.#selectedComponentKey = designator
182
+ this.#applySelectedComponent()
183
+ }
184
+
185
+ /**
186
+ * Releases event listeners and runtime resources.
187
+ * @returns {void}
188
+ */
189
+ dispose() {
190
+ this.#isDisposed = true
191
+ this.#listeners.forEach(({ node, type, listener }) => {
192
+ node.removeEventListener?.(type, listener)
193
+ })
194
+ this.#listeners = []
195
+ this.#scenePrepClient?.dispose?.()
196
+ this.#scenePrepClient = null
197
+ this.#runtime?.dispose?.()
198
+ this.#runtime = null
199
+ this.#sceneDescription = null
200
+ this.#viewportNode = null
201
+ this.#documentModel = null
202
+ this.#rootNode = null
203
+ this.#diagnosticsNode = null
204
+ this.#selectionNode = null
205
+ this.#selectedComponentKey = ''
206
+ this.#exportArchive = async () => ({
207
+ archiveName: '',
208
+ archiveBytes: new Uint8Array(),
209
+ exportedEntries: [],
210
+ skippedEntries: []
211
+ })
212
+ this.#downloadArchive = async () => {}
213
+ this.#selectionIndex = new Map()
214
+ }
215
+
216
+ /**
217
+ * Builds the scene description, mounts the runtime, and settles loading
218
+ * only after the runtime is fully ready.
219
+ * @param {{ sessionAssets?: any[], buildScene?: (documentModel: any, options: { modelRegistry: any }) => any, createRuntime?: (viewportNode: HTMLElement, sceneDescription: any, hooks: { setDiagnostics: (messages: string[]) => void, setSelection: (selection: any | null) => void }) => { setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, dispose?: () => void, whenReady?: () => Promise<void> | void } }} options
220
+ * @returns {Promise<void>}
221
+ */
222
+ async #initializeScene(options) {
223
+ try {
224
+ const sceneDescription =
225
+ await this.#prepareSceneDescription(options)
226
+ if (this.#isDisposed || !this.#viewportNode) {
227
+ return
228
+ }
229
+
230
+ this.#mountScene(sceneDescription, options)
231
+ await this.#runtime?.whenReady?.()
232
+ if (this.#isDisposed) {
233
+ return
234
+ }
235
+
236
+ this.#setLoadingVisible(false)
237
+ } catch (error) {
238
+ if (this.#isDisposed) {
239
+ return
240
+ }
241
+
242
+ this.#setDiagnostics([
243
+ this.#translate('scene3d.startFailed') +
244
+ ' ' +
245
+ String(error?.message || error || 'Unknown error.')
246
+ ])
247
+ this.#setLoadingVisible(false)
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Initializes the local fallback scene path synchronously so the existing
253
+ * non-worker controller behavior remains unchanged.
254
+ * @param {{ sessionAssets?: any[], buildScene?: (documentModel: any, options: { modelRegistry: any }) => any, createRuntime?: (viewportNode: HTMLElement, sceneDescription: any, hooks: { setDiagnostics: (messages: string[]) => void, setSelection: (selection: any | null) => void }) => { setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, dispose?: () => void, whenReady?: () => Promise<void> | void } }} options
255
+ * @returns {void}
256
+ */
257
+ #initializeSceneSync(options) {
258
+ try {
259
+ const sceneDescription = this.#prepareSceneDescriptionSync(options)
260
+ this.#mountScene(sceneDescription, options)
261
+ Promise.resolve(this.#runtime?.whenReady?.()).finally(() => {
262
+ if (this.#isDisposed) {
263
+ return
264
+ }
265
+
266
+ this.#setLoadingVisible(false)
267
+ })
268
+ } catch (error) {
269
+ this.#setDiagnostics([
270
+ this.#translate('scene3d.startFailed') +
271
+ ' ' +
272
+ String(error?.message || error || 'Unknown error.')
273
+ ])
274
+ this.#setLoadingVisible(false)
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Prepares the scene description either through the dedicated worker
280
+ * client or the local fallback path.
281
+ * @param {{ sessionAssets?: any[], buildScene?: (documentModel: any, options: { modelRegistry: any }) => any }} options
282
+ * @returns {Promise<any>}
283
+ */
284
+ async #prepareSceneDescription(options) {
285
+ if (this.#scenePrepClient?.prepareScene) {
286
+ try {
287
+ return await this.#scenePrepClient.prepareScene(
288
+ this.#documentModel,
289
+ options.sessionAssets || []
290
+ )
291
+ } catch (_error) {
292
+ // Fall back to the local path when the dedicated 3D worker is
293
+ // unavailable so the tab still renders.
294
+ }
295
+ }
296
+
297
+ return this.#prepareSceneDescriptionSync(options)
298
+ }
299
+
300
+ /**
301
+ * Prepares the local fallback scene description synchronously.
302
+ * @param {{ sessionAssets?: any[], buildScene?: (documentModel: any, options: { modelRegistry: any }) => any }} options
303
+ * @returns {any}
304
+ */
305
+ #prepareSceneDescriptionSync(options) {
306
+ const buildScene = options.buildScene
307
+ if (typeof buildScene !== 'function') {
308
+ throw new Error(
309
+ 'PcbScene3dController requires buildScene, sceneDescription, or scenePrepClient.'
310
+ )
311
+ }
312
+
313
+ const modelRegistry =
314
+ typeof options.createModelRegistry === 'function'
315
+ ? options.createModelRegistry(
316
+ this.#documentModel,
317
+ options.sessionAssets || []
318
+ )
319
+ : null
320
+
321
+ return buildScene(this.#documentModel, {
322
+ modelRegistry
323
+ })
324
+ }
325
+
326
+ /**
327
+ * Mounts the runtime for one prepared scene description.
328
+ * @param {any} sceneDescription
329
+ * @param {{ createRuntime?: (viewportNode: HTMLElement, sceneDescription: any, hooks: { setDiagnostics: (messages: string[]) => void, setSelection: (selection: any | null) => void, translate?: ((key: string) => string) | null }) => { setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, dispose?: () => void, whenReady?: () => Promise<void> | void } }} options
330
+ * @returns {void}
331
+ */
332
+ #mountScene(sceneDescription, options) {
333
+ const renderModel =
334
+ PcbScene3dController.#normalizeSceneDescription(sceneDescription)
335
+ this.#sceneDescription = renderModel
336
+ this.#selectionIndex =
337
+ PcbScene3dController.#buildSelectionIndex(renderModel)
338
+ const createRuntime =
339
+ options.createRuntime ||
340
+ ((nextViewportNode, nextSceneDescription, hooks) =>
341
+ new PcbScene3dRuntime(
342
+ nextViewportNode,
343
+ nextSceneDescription,
344
+ hooks
345
+ ))
346
+
347
+ this.#runtime = createRuntime(this.#viewportNode, renderModel, {
348
+ setDiagnostics: (messages) => this.#setDiagnostics(messages),
349
+ setSelection: (selection) =>
350
+ this.#handleRuntimeSelection(selection),
351
+ translate: this.#translate
352
+ })
353
+ if (this.#selectedComponentKey) {
354
+ this.#applySelectedComponent()
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Binds toolbar camera preset buttons.
360
+ * @returns {void}
361
+ */
362
+ #bindPresets() {
363
+ const buttons =
364
+ this.#rootNode?.querySelectorAll('[data-scene-3d-preset]') || []
365
+
366
+ buttons.forEach((button) => {
367
+ const listener = () => {
368
+ const presetName =
369
+ button?.getAttribute?.('data-scene-3d-preset') || ''
370
+ this.#setActivePresetButton(presetName)
371
+ this.#runtime?.setPreset?.(presetName)
372
+ }
373
+
374
+ button.addEventListener?.('click', listener)
375
+ this.#listeners.push({
376
+ node: button,
377
+ type: 'click',
378
+ listener
379
+ })
380
+ })
381
+ }
382
+
383
+ /**
384
+ * Marks exactly one preset button as active in the 3D toolbar.
385
+ * @param {string} activePreset
386
+ * @returns {void}
387
+ */
388
+ #setActivePresetButton(activePreset) {
389
+ const normalizedPreset = String(activePreset || '')
390
+ .trim()
391
+ .toLowerCase()
392
+ const buttons =
393
+ this.#rootNode?.querySelectorAll('[data-scene-3d-preset]') || []
394
+
395
+ buttons.forEach((button) => {
396
+ const presetName = String(
397
+ button?.getAttribute?.('data-scene-3d-preset') || ''
398
+ )
399
+ .trim()
400
+ .toLowerCase()
401
+ const isActive =
402
+ Boolean(normalizedPreset) && presetName === normalizedPreset
403
+
404
+ button?.setAttribute?.('aria-pressed', isActive ? 'true' : 'false')
405
+ if (isActive) {
406
+ button?.classList?.add?.('is-active')
407
+ return
408
+ }
409
+
410
+ button?.classList?.remove?.('is-active')
411
+ })
412
+ }
413
+
414
+ /**
415
+ * Binds detail toggle controls.
416
+ * @returns {void}
417
+ */
418
+ #bindToggles() {
419
+ const toggles =
420
+ this.#rootNode?.querySelectorAll('[data-scene-3d-toggle]') || []
421
+
422
+ toggles.forEach((toggle) => {
423
+ const listener = () => {
424
+ const toggleName =
425
+ toggle?.getAttribute?.('data-scene-3d-toggle') || ''
426
+ this.#runtime?.setToggle?.(toggleName, Boolean(toggle.checked))
427
+ }
428
+
429
+ toggle.addEventListener?.('change', listener)
430
+ this.#listeners.push({
431
+ node: toggle,
432
+ type: 'change',
433
+ listener
434
+ })
435
+ })
436
+ }
437
+
438
+ /**
439
+ * Binds the model archive export action.
440
+ * @returns {void}
441
+ */
442
+ #bindExportAction() {
443
+ const exportButton = this.#rootNode?.querySelector(
444
+ '[data-scene-3d-export="models-zip"]'
445
+ )
446
+ if (!exportButton) {
447
+ return
448
+ }
449
+
450
+ const listener = async () => {
451
+ await this.#handleExportAction()
452
+ }
453
+
454
+ exportButton.addEventListener?.('click', listener)
455
+ this.#listeners.push({
456
+ node: exportButton,
457
+ type: 'click',
458
+ listener
459
+ })
460
+ }
461
+
462
+ /**
463
+ * Exports the currently resolved 3D model set as one ZIP archive.
464
+ * @returns {Promise<void>}
465
+ */
466
+ async #handleExportAction() {
467
+ if (!this.#sceneDescription) {
468
+ this.#setDiagnostics([this.#translate('scene3d.stillPreparing')])
469
+ return
470
+ }
471
+
472
+ try {
473
+ const archiveResult = await this.#exportArchive({
474
+ archiveBaseName: PcbScene3dController.#resolveArchiveBaseName(
475
+ this.#documentModel
476
+ ),
477
+ sceneDescription: this.#sceneDescription
478
+ })
479
+
480
+ if (this.#isDisposed) {
481
+ return
482
+ }
483
+
484
+ const exportedCount = Array.isArray(archiveResult?.exportedEntries)
485
+ ? archiveResult.exportedEntries.length
486
+ : 0
487
+ const skippedCount = Array.isArray(archiveResult?.skippedEntries)
488
+ ? archiveResult.skippedEntries.length
489
+ : 0
490
+
491
+ if (!exportedCount) {
492
+ this.#setDiagnostics([
493
+ this.#translate('scene3d.noModelsForExport')
494
+ ])
495
+ return
496
+ }
497
+
498
+ await this.#downloadArchive(
499
+ String(archiveResult?.archiveName || ''),
500
+ archiveResult?.archiveBytes instanceof Uint8Array
501
+ ? archiveResult.archiveBytes
502
+ : new Uint8Array()
503
+ )
504
+
505
+ if (this.#isDisposed) {
506
+ return
507
+ }
508
+
509
+ const noun =
510
+ exportedCount === 1
511
+ ? this.#translate('scene3d.modelFile')
512
+ : this.#translate('scene3d.modelFiles')
513
+ const skippedSummary =
514
+ skippedCount > 0
515
+ ? ' ' +
516
+ this.#translate('scene3d.skipped') +
517
+ ' ' +
518
+ skippedCount +
519
+ ' ' +
520
+ this.#translate('scene3d.unresolved') +
521
+ ' ' +
522
+ (skippedCount === 1
523
+ ? this.#translate('scene3d.entry')
524
+ : this.#translate('scene3d.entries')) +
525
+ '.'
526
+ : ''
527
+ this.#setDiagnostics([
528
+ this.#translate('scene3d.downloaded') +
529
+ ' ' +
530
+ exportedCount +
531
+ ' ' +
532
+ noun +
533
+ ' ' +
534
+ this.#translate('scene3d.to') +
535
+ ' ' +
536
+ String(archiveResult.archiveName || 'model archive') +
537
+ '.' +
538
+ skippedSummary
539
+ ])
540
+ } catch (error) {
541
+ if (this.#isDisposed) {
542
+ return
543
+ }
544
+
545
+ this.#setDiagnostics([
546
+ this.#translate('scene3d.exportFailed') +
547
+ ' ' +
548
+ String(error?.message || error || 'Unknown error.')
549
+ ])
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Renders diagnostic messages into the scene panel.
555
+ * @param {string[]} messages
556
+ * @returns {void}
557
+ */
558
+ #setDiagnostics(messages) {
559
+ if (!this.#diagnosticsNode) {
560
+ return
561
+ }
562
+
563
+ const list = Array.isArray(messages) ? messages.filter(Boolean) : []
564
+ this.#diagnosticsNode.textContent = list.length
565
+ ? list.join(' ')
566
+ : PcbScene3dInteractionHints.resolveDefaultMessage(
567
+ globalThis.window,
568
+ this.#translate
569
+ )
570
+ }
571
+
572
+ /**
573
+ * Updates the inspector and forwards direct 3D picks into shared state.
574
+ * @param {{ designator?: string, sourceType?: string } | null} selection
575
+ * @returns {void}
576
+ */
577
+ #handleRuntimeSelection(selection) {
578
+ this.#setSelection(selection)
579
+ const designator = String(selection?.designator || '').trim()
580
+ this.#selectedComponentKey = designator
581
+ this.#onComponentSelectionChange?.({
582
+ documentId: this.#documentId,
583
+ componentKey: designator,
584
+ source: '3d-scene'
585
+ })
586
+ }
587
+
588
+ /**
589
+ * Applies the stored selected component to the mounted runtime and
590
+ * inspector, if any selection is active.
591
+ * @returns {void}
592
+ */
593
+ #applySelectedComponent() {
594
+ this.#runtime?.setSelectedDesignator?.(this.#selectedComponentKey)
595
+ this.#setSelection(
596
+ this.#selectedComponentKey
597
+ ? {
598
+ designator: this.#selectedComponentKey,
599
+ sourceType: 'sidebar'
600
+ }
601
+ : null
602
+ )
603
+ }
604
+
605
+ /**
606
+ * Renders the selected component details into the inspector panel.
607
+ * @param {{ designator?: string, sourceType?: string } | null} selection
608
+ * @returns {void}
609
+ */
610
+ #setSelection(selection) {
611
+ if (!this.#selectionNode) {
612
+ return
613
+ }
614
+
615
+ const designator = String(selection?.designator || '').trim()
616
+ if (!designator) {
617
+ this.#selectionNode.innerHTML =
618
+ '<h4 class="scene-3d__selection-title">' +
619
+ PcbScene3dController.#escapeHtml(
620
+ this.#translate('scene3d.componentInspector')
621
+ ) +
622
+ '</h4><p class="scene-3d__selection-empty">' +
623
+ PcbScene3dController.#escapeHtml(
624
+ this.#translate('scene3d.inspectPrompt')
625
+ ) +
626
+ '</p>'
627
+ return
628
+ }
629
+
630
+ const selectionEntry = this.#selectionIndex.get(designator)
631
+ if (!selectionEntry) {
632
+ this.#selectionNode.innerHTML =
633
+ '<h4 class="scene-3d__selection-title">' +
634
+ PcbScene3dController.#escapeHtml(
635
+ this.#translate('scene3d.componentInspector')
636
+ ) +
637
+ '</h4><p class="scene-3d__selection-empty">' +
638
+ PcbScene3dController.#escapeHtml(
639
+ this.#translate('scene3d.noMetadataFor')
640
+ ) +
641
+ ' ' +
642
+ PcbScene3dController.#escapeHtml(designator) +
643
+ '.</p>'
644
+ return
645
+ }
646
+
647
+ const component = selectionEntry.component
648
+ const externalPlacement = selectionEntry.externalPlacement
649
+ const fields = [
650
+ [this.#translate('scene3d.designator'), designator],
651
+ [
652
+ this.#translate('scene3d.picked'),
653
+ selection?.sourceType === 'external-model'
654
+ ? this.#translate('scene3d.externalModel')
655
+ : this.#translate('scene3d.fallbackBody')
656
+ ],
657
+ [
658
+ this.#translate('scene3d.mountSide'),
659
+ externalPlacement?.mountSide || component?.mountSide || ''
660
+ ],
661
+ [
662
+ this.#translate('scene3d.rotation'),
663
+ PcbScene3dController.#formatMilValue(
664
+ component?.rotationDeg ?? externalPlacement?.rotationDeg,
665
+ 'deg'
666
+ )
667
+ ],
668
+ [
669
+ this.#translate('scene3d.boardPosition'),
670
+ component?.boardPositionMil
671
+ ? PcbScene3dController.#formatPoint(
672
+ component.boardPositionMil,
673
+ true
674
+ )
675
+ : ''
676
+ ],
677
+ [
678
+ this.#translate('scene3d.pattern'),
679
+ String(component?.pattern || '')
680
+ ],
681
+ [
682
+ this.#translate('scene3d.source'),
683
+ String(component?.source || '')
684
+ ],
685
+ [
686
+ this.#translate('scene3d.model'),
687
+ externalPlacement?.externalModel
688
+ ? String(externalPlacement.externalModel.name || '') +
689
+ ' (' +
690
+ String(externalPlacement.externalModel.format || '') +
691
+ ')'
692
+ : component?.externalModel
693
+ ? String(component.externalModel.name || '') +
694
+ ' (' +
695
+ String(component.externalModel.format || '') +
696
+ ')'
697
+ : ''
698
+ ],
699
+ [
700
+ this.#translate('scene3d.bodyPosition'),
701
+ externalPlacement?.bodyPositionMil
702
+ ? PcbScene3dController.#formatPoint(
703
+ externalPlacement.bodyPositionMil,
704
+ false
705
+ )
706
+ : ''
707
+ ],
708
+ [
709
+ this.#translate('scene3d.bodyRotation'),
710
+ PcbScene3dController.#formatMilValue(
711
+ externalPlacement?.bodyRotationDeg,
712
+ 'deg'
713
+ )
714
+ ],
715
+ [
716
+ this.#translate('scene3d.modelRotation'),
717
+ externalPlacement?.modelTransform?.rotationDeg
718
+ ? 'X ' +
719
+ PcbScene3dController.#formatNumber(
720
+ externalPlacement.modelTransform.rotationDeg.x
721
+ ) +
722
+ ', Y ' +
723
+ PcbScene3dController.#formatNumber(
724
+ externalPlacement.modelTransform.rotationDeg.y
725
+ ) +
726
+ ', Z ' +
727
+ PcbScene3dController.#formatNumber(
728
+ externalPlacement.modelTransform.rotationDeg.z
729
+ )
730
+ : ''
731
+ ],
732
+ [
733
+ 'dz',
734
+ PcbScene3dController.#formatMilValue(
735
+ externalPlacement?.modelTransform?.dzMil,
736
+ 'mil'
737
+ )
738
+ ]
739
+ ].filter(([, value]) => String(value || '').trim())
740
+
741
+ this.#selectionNode.innerHTML =
742
+ '<h4 class="scene-3d__selection-title">' +
743
+ PcbScene3dController.#escapeHtml(
744
+ this.#translate('scene3d.componentInspector')
745
+ ) +
746
+ '</h4><dl class="scene-3d__selection-list">' +
747
+ fields
748
+ .map(
749
+ ([label, value]) =>
750
+ '<div class="scene-3d__selection-field"><dt>' +
751
+ PcbScene3dController.#escapeHtml(label) +
752
+ '</dt><dd>' +
753
+ PcbScene3dController.#escapeHtml(String(value)) +
754
+ '</dd></div>'
755
+ )
756
+ .join('') +
757
+ '</dl>'
758
+ }
759
+
760
+ /**
761
+ * Finds a CircuitJSON model supplied either explicitly or as the document.
762
+ * @param {{ circuitJson?: object[] }} options Controller options.
763
+ * @param {any} documentModel Mounted document model.
764
+ * @returns {object[] | null}
765
+ */
766
+ static #resolveCircuitJsonModel(options, documentModel) {
767
+ if (
768
+ PcbScene3dCircuitJsonAdapter.isCircuitJsonModel(options.circuitJson)
769
+ ) {
770
+ return options.circuitJson
771
+ }
772
+ if (
773
+ PcbScene3dCircuitJsonAdapter.isDirectCircuitJsonModel(documentModel)
774
+ ) {
775
+ return documentModel
776
+ }
777
+ return null
778
+ }
779
+
780
+ /**
781
+ * Converts direct CircuitJSON inputs into the renderer scene model.
782
+ * @param {any} sceneDescription Scene description or CircuitJSON model.
783
+ * @returns {any}
784
+ */
785
+ static #normalizeSceneDescription(sceneDescription) {
786
+ return PcbScene3dCircuitJsonAdapter.isDirectCircuitJsonModel(
787
+ sceneDescription
788
+ )
789
+ ? PcbScene3dCircuitJsonAdapter.build(sceneDescription)
790
+ : sceneDescription
791
+ }
792
+
793
+ /**
794
+ * Builds one designator-keyed inspector lookup from the scene
795
+ * description.
796
+ * @param {{ components?: any[], externalPlacements?: any[] }} sceneDescription
797
+ * @returns {Map<string, { component: any | null, externalPlacement: any | null }>}
798
+ */
799
+ static #buildSelectionIndex(sceneDescription) {
800
+ const index = new Map()
801
+ const components = Array.isArray(sceneDescription?.components)
802
+ ? sceneDescription.components
803
+ : []
804
+ const externalPlacements = Array.isArray(
805
+ sceneDescription?.externalPlacements
806
+ )
807
+ ? sceneDescription.externalPlacements
808
+ : []
809
+
810
+ components.forEach((component) => {
811
+ const designator = String(component?.designator || '').trim()
812
+ if (!designator) {
813
+ return
814
+ }
815
+
816
+ index.set(designator, {
817
+ component,
818
+ externalPlacement:
819
+ index.get(designator)?.externalPlacement || null
820
+ })
821
+ })
822
+
823
+ externalPlacements.forEach((externalPlacement) => {
824
+ const designator = String(
825
+ externalPlacement?.designator || ''
826
+ ).trim()
827
+ if (!designator) {
828
+ return
829
+ }
830
+
831
+ index.set(designator, {
832
+ component: index.get(designator)?.component || null,
833
+ externalPlacement
834
+ })
835
+ })
836
+
837
+ return index
838
+ }
839
+
840
+ /**
841
+ * Formats one point for the inspector.
842
+ * @param {{ x?: number, y?: number, z?: number }} point
843
+ * @param {boolean} includeZ
844
+ * @returns {string}
845
+ */
846
+ static #formatPoint(point, includeZ) {
847
+ const values = [
848
+ 'X ' + PcbScene3dController.#formatNumber(point?.x),
849
+ 'Y ' + PcbScene3dController.#formatNumber(point?.y)
850
+ ]
851
+
852
+ if (includeZ) {
853
+ values.push('Z ' + PcbScene3dController.#formatNumber(point?.z))
854
+ }
855
+
856
+ return values.join(', ') + ' mil'
857
+ }
858
+
859
+ /**
860
+ * Formats one numeric inspector value with an optional unit.
861
+ * @param {number | undefined} value
862
+ * @param {string} unit
863
+ * @returns {string}
864
+ */
865
+ static #formatMilValue(value, unit) {
866
+ if (!Number.isFinite(Number(value))) {
867
+ return ''
868
+ }
869
+
870
+ return PcbScene3dController.#formatNumber(value) + ' ' + unit
871
+ }
872
+
873
+ /**
874
+ * Formats one number for compact UI display.
875
+ * @param {number | undefined} value
876
+ * @returns {string}
877
+ */
878
+ static #formatNumber(value) {
879
+ const numericValue = Number(value)
880
+ if (!Number.isFinite(numericValue)) {
881
+ return ''
882
+ }
883
+
884
+ return numericValue.toFixed(2).replace(/\.00$/, '')
885
+ }
886
+
887
+ /**
888
+ * Escapes user-facing HTML values.
889
+ * @param {string} value
890
+ * @returns {string}
891
+ */
892
+ static #escapeHtml(value) {
893
+ return String(value)
894
+ .replace(/&/g, '&amp;')
895
+ .replace(/</g, '&lt;')
896
+ .replace(/>/g, '&gt;')
897
+ .replace(/"/g, '&quot;')
898
+ .replace(/'/g, '&#39;')
899
+ }
900
+
901
+ /**
902
+ * Resolves one archive base name from the mounted document metadata.
903
+ * @param {{ summary?: { title?: string }, fileName?: string } | null} documentModel
904
+ * @returns {string}
905
+ */
906
+ static #resolveArchiveBaseName(documentModel) {
907
+ const summaryTitle = String(documentModel?.summary?.title || '').trim()
908
+ if (summaryTitle) {
909
+ return summaryTitle
910
+ }
911
+
912
+ const fileName = String(documentModel?.fileName || '').trim()
913
+ if (fileName) {
914
+ return fileName.replace(/\.[^.]+$/, '')
915
+ }
916
+
917
+ return 'pcb-models'
918
+ }
919
+
920
+ /**
921
+ * Triggers one browser download for the generated archive.
922
+ * @param {string} archiveName
923
+ * @param {Uint8Array} archiveBytes
924
+ * @returns {Promise<void>}
925
+ */
926
+ static async #triggerArchiveDownload(archiveName, archiveBytes) {
927
+ if (
928
+ !archiveName ||
929
+ !(archiveBytes instanceof Uint8Array) ||
930
+ !archiveBytes.length ||
931
+ !globalThis?.document ||
932
+ !globalThis?.URL
933
+ ) {
934
+ return
935
+ }
936
+
937
+ const anchor = globalThis.document.createElement?.('a')
938
+ if (!anchor) {
939
+ return
940
+ }
941
+
942
+ const objectUrl = globalThis.URL.createObjectURL(
943
+ new Blob([archiveBytes], {
944
+ type: 'application/zip'
945
+ })
946
+ )
947
+
948
+ try {
949
+ anchor.href = objectUrl
950
+ anchor.download = archiveName
951
+ anchor.click?.()
952
+ } finally {
953
+ globalThis.URL.revokeObjectURL?.(objectUrl)
954
+ }
955
+ }
956
+ }