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,996 @@
1
+ import { PcbScene3dBoardSolderMaskFactory } from './PcbScene3dBoardSolderMaskFactory.mjs'
2
+ import { PcbScene3dCameraRig } from './PcbScene3dCameraRig.mjs'
3
+ import { PcbScene3dCircuitJsonAdapter } from './PcbScene3dCircuitJsonAdapter.mjs'
4
+ import { PcbScene3dCopperFactory } from './PcbScene3dCopperFactory.mjs'
5
+ import { PcbScene3dCopperDetailFilter } from './PcbScene3dCopperDetailFilter.mjs'
6
+ import { PcbScene3dDetailCoordinateNormalizer } from './PcbScene3dDetailCoordinateNormalizer.mjs'
7
+ import { PcbScene3dDrillVoidFactory } from './PcbScene3dDrillVoidFactory.mjs'
8
+ import { PcbScene3dExternalModels } from './PcbScene3dExternalModels.mjs'
9
+ import { PcbScene3dFallbackVisibility } from './PcbScene3dFallbackVisibility.mjs'
10
+ import { PcbScene3dInteractionHints } from './PcbScene3dInteractionHints.mjs'
11
+ import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
12
+ import { PcbScene3dPresetState } from './PcbScene3dPresetState.mjs'
13
+ import { PcbScene3dRenderGroupVisibility } from './PcbScene3dRenderGroupVisibility.mjs'
14
+ import { PcbScene3dRuntimeBoardMeshes } from './PcbScene3dRuntimeBoardMeshes.mjs'
15
+ import { PcbScene3dSilkscreenFactory } from './PcbScene3dSilkscreenFactory.mjs'
16
+ import { PcbScene3dTrueTypeTextFactory } from './PcbScene3dTrueTypeTextFactory.mjs'
17
+ import { PcbScene3dSelectionStyler } from './PcbScene3dSelectionStyler.mjs'
18
+ import { PcbScene3dViaFactory } from './PcbScene3dViaFactory.mjs'
19
+ import { PcbScene3dViewportResize } from './PcbScene3dViewportResize.mjs'
20
+ import { PcbScene3dViewScale } from './PcbScene3dViewScale.mjs'
21
+ /**
22
+ * Browser-side Three.js runtime for the interactive PCB 3D viewport.
23
+ */
24
+ export class PcbScene3dRuntime {
25
+ /** @type {HTMLElement | null} */
26
+ #viewportNode
27
+ /** @type {any} */
28
+ #sceneDescription
29
+ /** @type {{ setDiagnostics?: (messages: string[]) => void, setSelection?: (selection: any | null) => void, loadRuntimeModules?: () => Promise<{ THREE: any, OrbitControls: any }>, translate?: ((key: string) => string) | null }} */
30
+ #hooks
31
+ /** @type {{ 'external-models': boolean, 'fallback-bodies': boolean, copper: boolean }} */
32
+ #toggles
33
+ /** @type {Map<string, any>} */
34
+ #groups
35
+ /** @type {Array<{ node: EventTarget, type: string, listener: (event: any) => void }>} */
36
+ #listeners
37
+ /** @type {any} */
38
+ #three
39
+ /** @type {any} */
40
+ #renderer
41
+ /** @type {any} */
42
+ #scene
43
+ /** @type {any} */
44
+ #camera
45
+ /** @type {any} */
46
+ #orbitControlsClass
47
+ /** @type {any} */
48
+ #controls
49
+ /** @type {{ disconnect?: () => void } | null} */
50
+ #resizeObserver
51
+ /** @type {any} */
52
+ #rootGroup
53
+ /** @type {any} */
54
+ #viewOrientationGroup
55
+ /** @type {any} */
56
+ #raycaster
57
+ /** @type {any} */
58
+ #pointer
59
+ /** @type {{ x: number, y: number } | null} */
60
+ #pointerDownPosition
61
+ /** @type {Map<string, Set<any>>} */
62
+ #selectionRoots
63
+ /** @type {Map<string, Set<any>>} */
64
+ #fallbackBodyRoots
65
+ /** @type {Set<string>} */
66
+ #loadedExternalModelDesignators
67
+ /** @type {boolean} */
68
+ #hasLoadedBoardAssemblyModel
69
+ /** @type {string} */
70
+ #selectedDesignator
71
+ /** @type {number} */
72
+ #initialRadius
73
+ /** @type {PcbScene3dPresetState} */
74
+ #presetState
75
+ /** @type {boolean} */
76
+ #isDisposed
77
+ /** @type {Promise<void>} */
78
+ #readyPromise
79
+ /** @type {(() => void) | null} */
80
+ #resolveReadyPromise
81
+ /** @type {boolean} */
82
+ #hasSettledReady
83
+
84
+ /**
85
+ * @param {HTMLElement} viewportNode
86
+ * @param {any} sceneDescription Scene description or CircuitJSON model.
87
+ * @param {{ setDiagnostics?: (messages: string[]) => void, setSelection?: (selection: any | null) => void, loadRuntimeModules?: () => Promise<{ THREE: any, OrbitControls: any }>, translate?: ((key: string) => string) | null }} [hooks]
88
+ */
89
+ constructor(viewportNode, sceneDescription, hooks = {}) {
90
+ const renderModel =
91
+ PcbScene3dRuntime.#normalizeSceneDescription(sceneDescription)
92
+ this.#viewportNode = viewportNode
93
+ this.#sceneDescription = renderModel
94
+ this.#hooks = hooks
95
+ this.#toggles = {
96
+ 'external-models': true,
97
+ 'fallback-bodies': false,
98
+ copper: true
99
+ }
100
+ this.#groups = new Map()
101
+ this.#listeners = []
102
+ this.#three = null
103
+ this.#renderer = null
104
+ this.#scene = null
105
+ this.#camera = null
106
+ this.#orbitControlsClass = null
107
+ this.#controls = null
108
+ this.#resizeObserver = null
109
+ this.#rootGroup = null
110
+ this.#viewOrientationGroup = null
111
+ this.#raycaster = null
112
+ this.#pointer = null
113
+ this.#pointerDownPosition = null
114
+ this.#selectionRoots = new Map()
115
+ this.#fallbackBodyRoots = new Map()
116
+ this.#loadedExternalModelDesignators = new Set()
117
+ this.#hasLoadedBoardAssemblyModel = false
118
+ this.#selectedDesignator = ''
119
+ this.#initialRadius =
120
+ PcbScene3dCameraRig.resolveInitialRadius(renderModel)
121
+ this.#presetState = new PcbScene3dPresetState()
122
+ this.#isDisposed = false
123
+ this.#hasSettledReady = false
124
+ this.#resolveReadyPromise = null
125
+ this.#readyPromise = new Promise((resolve) => {
126
+ this.#resolveReadyPromise = resolve
127
+ })
128
+ this.#hooks.setDiagnostics?.([
129
+ PcbScene3dInteractionHints.resolveDefaultMessage(
130
+ globalThis.window,
131
+ this.#hooks.translate || null
132
+ )
133
+ ])
134
+ this.#initialize()
135
+ }
136
+
137
+ /** @param {string} preset */
138
+ setPreset(preset) {
139
+ const normalizedPreset = this.#presetState.set(preset)
140
+ this.#applyViewScale(normalizedPreset)
141
+ if (!this.#camera) {
142
+ return
143
+ }
144
+
145
+ PcbScene3dCameraRig.applyPreset(
146
+ this.#camera,
147
+ this.#controls,
148
+ normalizedPreset,
149
+ this.#sceneDescription
150
+ )
151
+ this.#render()
152
+ }
153
+
154
+ /** @param {string} toggleName @param {boolean} enabled */
155
+ setToggle(toggleName, enabled) {
156
+ if (!(toggleName in this.#toggles)) {
157
+ return
158
+ }
159
+
160
+ this.#toggles[toggleName] = enabled
161
+ this.#applyToggleVisibility()
162
+ this.#render()
163
+ }
164
+
165
+ /** @param {string} designator */
166
+ setSelectedDesignator(designator) {
167
+ this.#setSelectedDesignator(designator)
168
+ }
169
+
170
+ /**
171
+ * Resolves when the runtime has completed its initial async
172
+ * initialization and deferred scene settlement.
173
+ * @returns {Promise<void>}
174
+ */
175
+ whenReady() {
176
+ return this.#readyPromise
177
+ }
178
+
179
+ /** @returns {void} */
180
+ dispose() {
181
+ this.#isDisposed = true
182
+ this.#listeners.forEach(({ node, type, listener }) => {
183
+ node.removeEventListener?.(type, listener)
184
+ })
185
+ this.#listeners = []
186
+ this.#controls?.dispose?.()
187
+ this.#resizeObserver?.disconnect?.()
188
+ this.#renderer?.dispose?.()
189
+ if (this.#renderer?.domElement?.remove) {
190
+ this.#renderer.domElement.remove()
191
+ }
192
+ this.#viewportNode = null
193
+ this.#sceneDescription = null
194
+ this.#renderer = null
195
+ this.#scene = null
196
+ this.#camera = null
197
+ this.#orbitControlsClass = null
198
+ this.#controls = null
199
+ this.#resizeObserver = null
200
+ this.#rootGroup = null
201
+ this.#viewOrientationGroup = null
202
+ this.#raycaster = null
203
+ this.#pointer = null
204
+ this.#pointerDownPosition = null
205
+ this.#selectionRoots.clear()
206
+ this.#fallbackBodyRoots.clear()
207
+ this.#loadedExternalModelDesignators.clear()
208
+ this.#hasLoadedBoardAssemblyModel = false
209
+ this.#selectedDesignator = ''
210
+ this.#groups.clear()
211
+ this.#settleReady()
212
+ }
213
+
214
+ /**
215
+ * Initializes the async Three.js scene.
216
+ * @returns {Promise<void>}
217
+ */
218
+ async #initialize() {
219
+ if (
220
+ typeof window === 'undefined' ||
221
+ typeof document === 'undefined' ||
222
+ !this.#viewportNode
223
+ ) {
224
+ this.#settleReady()
225
+ return
226
+ }
227
+
228
+ try {
229
+ const { THREE, OrbitControls } =
230
+ (await this.#hooks.loadRuntimeModules?.()) ||
231
+ (await PcbScene3dRuntime.#loadThreeRuntimeModules())
232
+ this.#three = THREE
233
+ this.#orbitControlsClass = OrbitControls
234
+ if (this.#isDisposed || !this.#viewportNode) {
235
+ return
236
+ }
237
+
238
+ this.#createRenderer()
239
+ this.#createSceneGraph()
240
+ this.#createControls()
241
+ this.#bindSelectionInteraction()
242
+ this.#applyToggleVisibility()
243
+ this.#render()
244
+ await this.#loadDeferredDetail()
245
+ this.#settleReady()
246
+ } catch (error) {
247
+ this.#hooks.setDiagnostics?.([
248
+ '3D preview could not start: ' +
249
+ String(error?.message || error || 'Unknown error.')
250
+ ])
251
+ this.#settleReady()
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Creates the Three.js renderer, camera, and scene.
257
+ * @returns {void}
258
+ */
259
+ #createRenderer() {
260
+ const THREE = this.#three
261
+ const size = PcbScene3dViewportResize.resolveSize(this.#viewportNode)
262
+
263
+ this.#renderer = new THREE.WebGLRenderer({
264
+ antialias: true,
265
+ alpha: true,
266
+ powerPreference: 'high-performance'
267
+ })
268
+ this.#renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
269
+ this.#renderer.setSize(size.width, size.height, false)
270
+ this.#renderer.domElement.className = 'scene-3d__canvas'
271
+ this.#renderer.domElement.style.width = '100%'
272
+ this.#renderer.domElement.style.height = '100%'
273
+
274
+ this.#scene = new THREE.Scene()
275
+ this.#camera = new THREE.PerspectiveCamera(
276
+ 38,
277
+ size.width / size.height,
278
+ 10,
279
+ Math.max(this.#initialRadius * 30, 25000)
280
+ )
281
+ this.#camera.up.set(0, 0, 1)
282
+ this.#viewportNode?.replaceChildren(this.#renderer.domElement)
283
+ }
284
+
285
+ /**
286
+ * Creates the initial board shell, fallback bodies, and placeholder detail
287
+ * groups used by the deferred loading stages.
288
+ * @returns {void}
289
+ */
290
+ #createSceneGraph() {
291
+ const THREE = this.#three
292
+ const board = this.#sceneDescription.board
293
+
294
+ this.#viewOrientationGroup = new THREE.Group()
295
+ this.#scene.add(this.#viewOrientationGroup)
296
+ this.#rootGroup = new THREE.Group()
297
+ this.#viewOrientationGroup.add(this.#rootGroup)
298
+ this.#applyViewScale(this.#presetState.get())
299
+ const ambientLight = new THREE.AmbientLight(0xffffff, 1.8)
300
+ const keyLight = new THREE.DirectionalLight(0xffffff, 1.6)
301
+ keyLight.position.set(0.8, 0.7, 1.3).multiplyScalar(this.#initialRadius)
302
+ const fillLight = new THREE.DirectionalLight(0xe8f3ff, 0.9)
303
+ fillLight.position
304
+ .set(-0.9, -0.4, 0.8)
305
+ .multiplyScalar(this.#initialRadius * 0.8)
306
+ this.#scene.add(ambientLight, keyLight, fillLight)
307
+ const boardGroup = new THREE.Group()
308
+ boardGroup.add(
309
+ PcbScene3dRuntimeBoardMeshes.buildBoardMesh(
310
+ THREE,
311
+ this.#sceneDescription,
312
+ (x, y) => this.#normalizeDetailPoint(x, y)
313
+ )
314
+ )
315
+ boardGroup.add(
316
+ PcbScene3dBoardSolderMaskFactory.buildGroup(
317
+ THREE,
318
+ this.#sceneDescription,
319
+ (x, y) => this.#normalizeDetailPoint(x, y)
320
+ )
321
+ )
322
+ boardGroup.add(
323
+ PcbScene3dDrillVoidFactory.buildGroup(
324
+ THREE,
325
+ this.#sceneDescription.detail,
326
+ board.thicknessMil / 2,
327
+ -board.thicknessMil / 2,
328
+ (x, y) => this.#normalizeDetailPoint(x, y),
329
+ {
330
+ enabled: Boolean(this.#sceneDescription.boardAssemblyModel),
331
+ board
332
+ }
333
+ )
334
+ )
335
+ boardGroup.add(
336
+ PcbScene3dRuntimeBoardMeshes.buildBoardOutline(
337
+ THREE,
338
+ this.#sceneDescription,
339
+ (x, y) => this.#normalizeDetailPoint(x, y)
340
+ )
341
+ )
342
+ this.#groups.set('board', boardGroup)
343
+ this.#rootGroup.add(boardGroup)
344
+ const silkscreenGroup = new THREE.Group()
345
+ this.#groups.set('silkscreen', silkscreenGroup)
346
+ this.#rootGroup.add(silkscreenGroup)
347
+ const copperGroup = new THREE.Group()
348
+ this.#groups.set('copper', copperGroup)
349
+ this.#rootGroup.add(copperGroup)
350
+ const fallbackBodiesGroup = new THREE.Group()
351
+ const externalModelsGroup = new THREE.Group()
352
+ this.#sceneDescription.components.forEach((component) => {
353
+ const fallbackBody = this.#buildFallbackBody(component)
354
+ fallbackBodiesGroup.add(fallbackBody)
355
+ PcbScene3dFallbackVisibility.registerFallbackRoot(
356
+ this.#fallbackBodyRoots,
357
+ component?.designator,
358
+ fallbackBody
359
+ )
360
+ })
361
+ this.#groups.set('fallback-bodies', fallbackBodiesGroup)
362
+ this.#groups.set('external-models', externalModelsGroup)
363
+ this.#rootGroup.add(fallbackBodiesGroup)
364
+ this.#rootGroup.add(externalModelsGroup)
365
+ const boardSpan = Math.max(board.widthMil, board.heightMil, 1)
366
+ this.#scene.fog = new THREE.Fog(
367
+ 0xf4f0ea,
368
+ boardSpan * 2.2,
369
+ boardSpan * 7
370
+ )
371
+ }
372
+
373
+ /**
374
+ * Applies the active preset's scene-scale transform.
375
+ * @param {string} preset
376
+ * @returns {void}
377
+ */
378
+ #applyViewScale(preset) {
379
+ if (!this.#viewOrientationGroup) {
380
+ return
381
+ }
382
+ const scale = PcbScene3dRuntime.resolveViewScale(
383
+ preset,
384
+ this.#sceneDescription
385
+ )
386
+ this.#viewOrientationGroup.scale.set(scale.x, scale.y, scale.z)
387
+ PcbScene3dExternalModels.applyViewCompensation(
388
+ this.#groups.get('silkscreen'),
389
+ scale
390
+ )
391
+ PcbScene3dExternalModels.applyViewCompensation(
392
+ this.#groups.get('external-models'),
393
+ scale
394
+ )
395
+ }
396
+
397
+ /**
398
+ * Loads silkscreen, copper, and external model detail after the initial
399
+ * shell render and keeps readiness pending until settlement completes.
400
+ * @returns {Promise<void>}
401
+ */
402
+ async #loadDeferredDetail() {
403
+ try {
404
+ await PcbScene3dRuntime.#yieldToNextFrame()
405
+ if (this.#isDisposed) {
406
+ return
407
+ }
408
+
409
+ await this.#loadDeferredSilkscreen()
410
+ this.#render()
411
+
412
+ await PcbScene3dRuntime.#yieldToNextFrame()
413
+ if (this.#isDisposed) {
414
+ return
415
+ }
416
+
417
+ this.#loadDeferredCopper()
418
+ this.#applyToggleVisibility()
419
+ this.#render()
420
+ this.#settleReady()
421
+ await PcbScene3dRuntime.#yieldToNextFrame()
422
+ if (this.#isDisposed) {
423
+ return
424
+ }
425
+
426
+ await this.#loadExternalModels()
427
+ if (this.#isDisposed) {
428
+ return
429
+ }
430
+
431
+ this.#render()
432
+ } catch (error) {
433
+ if (this.#isDisposed) {
434
+ return
435
+ }
436
+
437
+ this.#hooks.setDiagnostics?.([
438
+ 'Deferred 3D detail could not finish loading: ' +
439
+ String(error?.message || error || 'Unknown error.')
440
+ ])
441
+ this.#render()
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Builds and attaches silkscreen detail once after the first frame.
447
+ * @returns {Promise<void>}
448
+ */
449
+ async #loadDeferredSilkscreen() {
450
+ const silkscreenGroup = this.#groups.get('silkscreen')
451
+ if (!silkscreenGroup || silkscreenGroup.children.length) {
452
+ return
453
+ }
454
+
455
+ await PcbScene3dTrueTypeTextFactory.prepareEmbeddedFonts(
456
+ this.#sceneDescription.detail.embeddedFonts || []
457
+ )
458
+
459
+ const detailGroup = PcbScene3dSilkscreenFactory.buildGroup(
460
+ this.#three,
461
+ this.#sceneDescription.detail.silkscreen || {},
462
+ this.#sceneDescription.board.thicknessMil / 2 + 1.18,
463
+ -(this.#sceneDescription.board.thicknessMil / 2 + 1.18),
464
+ (x, y) => this.#normalizeDetailPoint(x, y)
465
+ )
466
+
467
+ if (detailGroup.children.length) {
468
+ silkscreenGroup.add(detailGroup)
469
+ this.#applyViewScale(this.#presetState.get())
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Builds and attaches copper and via detail once after the first frame.
475
+ * @returns {void}
476
+ */
477
+ #loadDeferredCopper() {
478
+ const copperGroup = this.#groups.get('copper')
479
+ if (!copperGroup || copperGroup.children.length) {
480
+ return
481
+ }
482
+
483
+ const copperDetail = PcbScene3dCopperDetailFilter.resolve(
484
+ this.#sceneDescription
485
+ )
486
+ const detailGroup = PcbScene3dCopperFactory.buildGroup(
487
+ this.#three,
488
+ copperDetail,
489
+ this.#sceneDescription.board.thicknessMil / 2 + 0.9,
490
+ -(this.#sceneDescription.board.thicknessMil / 2 + 0.9),
491
+ (x, y) => this.#normalizeDetailPoint(x, y),
492
+ { coordinateSystem: this.#sceneDescription.coordinateSystem }
493
+ )
494
+ const standaloneVias =
495
+ PcbScene3dCopperDetailFilter.resolveStandaloneVias(
496
+ this.#sceneDescription
497
+ )
498
+ const viaGroup =
499
+ PcbScene3dCopperDetailFilter.shouldRenderStandaloneVias(
500
+ this.#sceneDescription
501
+ )
502
+ ? PcbScene3dViaFactory.buildGroup(
503
+ this.#three,
504
+ standaloneVias,
505
+ this.#sceneDescription.board.thicknessMil,
506
+ (x, y) => this.#normalizeDetailPoint(x, y)
507
+ )
508
+ : new this.#three.Group()
509
+
510
+ if (detailGroup.children.length) {
511
+ copperGroup.add(detailGroup)
512
+ this.#applyViewScale(this.#presetState.get())
513
+ }
514
+ if (viaGroup.children.length) {
515
+ copperGroup.add(viaGroup)
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Builds one procedural fallback body mesh.
521
+ * @param {{ positionMil: { x: number, y: number, z: number }, rotationDeg: number, mountSide: string, body: { family: string, sizeMil: { width: number, depth: number, height: number } } }} component
522
+ * @returns {any}
523
+ */
524
+ #buildFallbackBody(component) {
525
+ const THREE = this.#three
526
+ const family = component.body.family
527
+ const size = component.body.sizeMil
528
+ const material = new THREE.MeshStandardMaterial({
529
+ color: PcbScene3dRuntime.#resolveBodyColor(family),
530
+ roughness: 0.72,
531
+ metalness: family === 'chip' ? 0.12 : 0.08
532
+ })
533
+
534
+ let mesh
535
+ if (family === 'radial-capacitor' || family === 'test-point') {
536
+ mesh = new THREE.Mesh(
537
+ new THREE.CylinderGeometry(
538
+ size.width / 2,
539
+ size.width / 2,
540
+ size.height,
541
+ 28
542
+ ),
543
+ material
544
+ )
545
+ } else {
546
+ mesh = new THREE.Mesh(
547
+ new THREE.BoxGeometry(size.width, size.depth, size.height),
548
+ material
549
+ )
550
+ }
551
+
552
+ const mountRig = PcbScene3dMountRig.create(THREE, component)
553
+ const rootGroup = mountRig.rootGroup
554
+
555
+ rootGroup.userData.scene3dSelection = {
556
+ designator: String(component?.designator || 'component'),
557
+ sourceType: 'component'
558
+ }
559
+ this.#registerSelectionRoot(component?.designator, rootGroup)
560
+ mountRig.faceGroup.add(mesh)
561
+
562
+ return rootGroup
563
+ }
564
+
565
+ /**
566
+ * Attempts to load any resolved external 3D models.
567
+ * @returns {Promise<void>}
568
+ */
569
+ async #loadExternalModels() {
570
+ const externalModelsGroup = this.#groups.get('external-models')
571
+ if (!externalModelsGroup) {
572
+ return
573
+ }
574
+
575
+ const diagnostics = await PcbScene3dExternalModels.loadIntoScene({
576
+ three: this.#three,
577
+ sceneDescription: this.#sceneDescription,
578
+ externalModelsGroup,
579
+ modelViewScale: PcbScene3dRuntime.resolveViewScale(
580
+ this.#presetState.get(),
581
+ this.#sceneDescription
582
+ ),
583
+ isDisposed: () => this.#isDisposed,
584
+ onPlacementGroup: (placement, placementGroup) => {
585
+ this.#registerSelectionRoot(
586
+ placement?.designator,
587
+ placementGroup
588
+ )
589
+ if (
590
+ String(placement?.sourceType || '').toLowerCase() ===
591
+ 'board-assembly'
592
+ ) {
593
+ this.#hasLoadedBoardAssemblyModel = true
594
+ } else {
595
+ PcbScene3dFallbackVisibility.markExternalModelLoaded(
596
+ this.#loadedExternalModelDesignators,
597
+ placement?.designator
598
+ )
599
+ }
600
+ this.#applyToggleVisibility()
601
+ }
602
+ })
603
+
604
+ if (diagnostics.length) {
605
+ this.#hooks.setDiagnostics?.(diagnostics)
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Creates and configures orbit/pan/zoom controls using Three's standard
611
+ * OrbitControls implementation in the same z-up world as the PCB scene.
612
+ * @returns {void}
613
+ */
614
+ #createControls() {
615
+ if (!this.#camera || !this.#renderer || !this.#orbitControlsClass) {
616
+ return
617
+ }
618
+
619
+ const THREE = this.#three
620
+ const domElement = this.#renderer?.domElement
621
+ if (!domElement) {
622
+ return
623
+ }
624
+
625
+ this.#controls = new this.#orbitControlsClass(this.#camera, domElement)
626
+ this.#controls.enableDamping = false
627
+ this.#controls.screenSpacePanning = true
628
+ this.#controls.minDistance = 140
629
+ this.#controls.maxDistance = this.#initialRadius * 8
630
+ this.#controls.target.set(0, 0, 0)
631
+ PcbScene3dInteractionHints.configureControls(this.#controls, THREE)
632
+
633
+ PcbScene3dCameraRig.applyPreset(
634
+ this.#camera,
635
+ this.#controls,
636
+ this.#presetState.get(),
637
+ this.#sceneDescription
638
+ )
639
+
640
+ this.#bindListener(this.#controls, 'change', () => this.#render())
641
+ this.#bindListener(domElement, 'contextmenu', (event) => {
642
+ event.preventDefault?.()
643
+ })
644
+ this.#bindListener(window, 'resize', () => this.#handleResize())
645
+ this.#resizeObserver = PcbScene3dViewportResize.observe(
646
+ globalThis,
647
+ this.#viewportNode,
648
+ () => this.#handleResize()
649
+ )
650
+ }
651
+
652
+ /** @returns {void} */
653
+ #bindSelectionInteraction() {
654
+ if (!this.#renderer || !this.#camera || !this.#three) {
655
+ return
656
+ }
657
+
658
+ this.#raycaster = new this.#three.Raycaster()
659
+ this.#pointer = new this.#three.Vector2()
660
+ const domElement = this.#renderer.domElement
661
+
662
+ this.#bindListener(domElement, 'pointerdown', (event) => {
663
+ if (Number(event?.button) !== 0) {
664
+ return
665
+ }
666
+
667
+ this.#pointerDownPosition = {
668
+ x: Number(event?.clientX || 0),
669
+ y: Number(event?.clientY || 0)
670
+ }
671
+ })
672
+ this.#bindListener(domElement, 'pointerup', (event) => {
673
+ if (Number(event?.button) !== 0) {
674
+ return
675
+ }
676
+
677
+ if (
678
+ !this.#pointerDownPosition ||
679
+ PcbScene3dRuntime.#resolvePointerTravel(
680
+ this.#pointerDownPosition,
681
+ {
682
+ x: Number(event?.clientX || 0),
683
+ y: Number(event?.clientY || 0)
684
+ }
685
+ ) > 4
686
+ ) {
687
+ this.#pointerDownPosition = null
688
+ return
689
+ }
690
+
691
+ this.#pointerDownPosition = null
692
+ this.#handleSelectionPointer(event)
693
+ })
694
+ }
695
+
696
+ /** @returns {void} */
697
+ #handleResize() {
698
+ if (!this.#renderer || !this.#camera) {
699
+ return
700
+ }
701
+
702
+ const size = PcbScene3dViewportResize.resolveSize(this.#viewportNode)
703
+ this.#renderer.setSize(size.width, size.height, false)
704
+ this.#camera.aspect = size.width / size.height
705
+ this.#camera.updateProjectionMatrix()
706
+ this.#controls?.update?.()
707
+ this.#render()
708
+ }
709
+
710
+ /** @returns {void} */
711
+ #applyToggleVisibility() {
712
+ PcbScene3dRenderGroupVisibility.apply({
713
+ groups: this.#groups,
714
+ toggles: this.#toggles,
715
+ fallbackBodyRoots: this.#fallbackBodyRoots,
716
+ loadedExternalModelDesignators:
717
+ this.#loadedExternalModelDesignators,
718
+ hasLoadedBoardAssemblyModel: this.#hasLoadedBoardAssemblyModel
719
+ })
720
+ }
721
+
722
+ /** @returns {void} */
723
+ #render() {
724
+ if (!this.#renderer || !this.#scene || !this.#camera) {
725
+ return
726
+ }
727
+
728
+ this.#renderer.render(this.#scene, this.#camera)
729
+ }
730
+
731
+ /**
732
+ * Settles the runtime-ready promise exactly once.
733
+ * @returns {void}
734
+ */
735
+ #settleReady() {
736
+ if (this.#hasSettledReady) {
737
+ return
738
+ }
739
+ this.#hasSettledReady = true
740
+ this.#resolveReadyPromise?.()
741
+ this.#resolveReadyPromise = null
742
+ }
743
+
744
+ /**
745
+ * Yields one turn so the browser can present the initial rendered frame
746
+ * before deferred detail work continues.
747
+ * @returns {Promise<void>}
748
+ */
749
+ static async #yieldToNextFrame() {
750
+ if (
751
+ typeof window !== 'undefined' &&
752
+ typeof window.requestAnimationFrame === 'function'
753
+ ) {
754
+ await new Promise((resolve) => {
755
+ window.requestAnimationFrame(() => resolve())
756
+ })
757
+ return
758
+ }
759
+ await new Promise((resolve) => {
760
+ globalThis.setTimeout(resolve, 0)
761
+ })
762
+ }
763
+
764
+ /**
765
+ * Normalizes one board coordinate into the scene's centered board plane.
766
+ * @param {number} x
767
+ * @param {number} y
768
+ * @returns {{ x: number, y: number }}
769
+ */
770
+ #normalizeBoardPoint(x, y) {
771
+ return {
772
+ x: Number(x || 0) - this.#sceneDescription.board.centerX,
773
+ y: Number(y || 0) - this.#sceneDescription.board.centerY
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Normalizes one detail coordinate into the scene's centered board plane.
779
+ * @param {number} x
780
+ * @param {number} y
781
+ * @returns {{ x: number, y: number }}
782
+ */
783
+ #normalizeDetailPoint(x, y) {
784
+ return PcbScene3dDetailCoordinateNormalizer.normalize(
785
+ this.#sceneDescription,
786
+ x,
787
+ y
788
+ )
789
+ }
790
+
791
+ /**
792
+ * Binds one runtime event listener for cleanup.
793
+ * @param {EventTarget} node
794
+ * @param {string} type
795
+ * @param {(event: any) => void} listener
796
+ * @returns {void}
797
+ */
798
+ #bindListener(node, type, listener) {
799
+ node.addEventListener?.(type, listener, { passive: false })
800
+ this.#listeners.push({ node, type, listener })
801
+ }
802
+
803
+ /**
804
+ * Resolves one click selection from the current pointer event.
805
+ * @param {{ clientX?: number, clientY?: number }} event
806
+ * @returns {void}
807
+ */
808
+ #handleSelectionPointer(event) {
809
+ if (
810
+ !this.#raycaster ||
811
+ !this.#pointer ||
812
+ !this.#camera ||
813
+ !this.#renderer
814
+ ) {
815
+ return
816
+ }
817
+
818
+ const rect = this.#renderer.domElement?.getBoundingClientRect?.()
819
+ const width = Math.max(Number(rect?.width || 0), 1)
820
+ const height = Math.max(Number(rect?.height || 0), 1)
821
+ const left = Number(rect?.left || 0)
822
+ const top = Number(rect?.top || 0)
823
+
824
+ this.#pointer.x = ((Number(event?.clientX || 0) - left) / width) * 2 - 1
825
+ this.#pointer.y = -(
826
+ ((Number(event?.clientY || 0) - top) / height) * 2 -
827
+ 1
828
+ )
829
+ this.#raycaster.setFromCamera(this.#pointer, this.#camera)
830
+
831
+ const selectableRoots = [
832
+ this.#groups.get('external-models'),
833
+ this.#groups.get('fallback-bodies')
834
+ ].filter((group) => group && group.visible !== false)
835
+ const intersections = this.#raycaster.intersectObjects(
836
+ selectableRoots,
837
+ true
838
+ )
839
+ const selection =
840
+ PcbScene3dRuntime.#resolveSelectionFromIntersections(intersections)
841
+ this.#setSelectedDesignator(selection?.designator)
842
+ this.#hooks.setSelection?.(selection)
843
+ }
844
+
845
+ /** @param {string} designator @param {any} rootObject */
846
+ #registerSelectionRoot(designator, rootObject) {
847
+ PcbScene3dSelectionStyler.registerSelectionRoot(
848
+ this.#selectionRoots,
849
+ designator,
850
+ rootObject
851
+ )
852
+
853
+ if (String(designator || '').trim() === this.#selectedDesignator) {
854
+ PcbScene3dSelectionStyler.applySelection(
855
+ this.#selectionRoots,
856
+ '',
857
+ this.#selectedDesignator,
858
+ PcbScene3dRuntime.#resolveSelectionHighlightColor()
859
+ )
860
+ }
861
+ }
862
+
863
+ /** @param {string | undefined} designator */
864
+ #setSelectedDesignator(designator) {
865
+ const normalizedDesignator = String(designator || '').trim()
866
+ if (normalizedDesignator === this.#selectedDesignator) {
867
+ return
868
+ }
869
+
870
+ PcbScene3dSelectionStyler.applySelection(
871
+ this.#selectionRoots,
872
+ this.#selectedDesignator,
873
+ normalizedDesignator,
874
+ PcbScene3dRuntime.#resolveSelectionHighlightColor()
875
+ )
876
+ this.#selectedDesignator = normalizedDesignator
877
+ this.#render()
878
+ }
879
+
880
+ /**
881
+ * Converts direct CircuitJSON inputs into the renderer scene model.
882
+ * @param {any} sceneDescription Scene description or CircuitJSON model.
883
+ * @returns {any}
884
+ */
885
+ static #normalizeSceneDescription(sceneDescription) {
886
+ return PcbScene3dCircuitJsonAdapter.isDirectCircuitJsonModel(
887
+ sceneDescription
888
+ )
889
+ ? PcbScene3dCircuitJsonAdapter.build(sceneDescription)
890
+ : sceneDescription
891
+ }
892
+
893
+ /**
894
+ * Loads the browser Three.js runtime modules used by the scene.
895
+ * @returns {Promise<{ THREE: any, OrbitControls: any }>}
896
+ */
897
+ static async #loadThreeRuntimeModules() {
898
+ const versionKey = new URL(import.meta.url).searchParams.get('v') || ''
899
+ const suffix = versionKey ? '?v=' + encodeURIComponent(versionKey) : ''
900
+ const [THREE, { OrbitControls }] = await Promise.all([
901
+ import('/node_modules/three/build/three.module.js' + suffix),
902
+ import(
903
+ '/node_modules/three/examples/jsm/controls/OrbitControls.js' +
904
+ suffix
905
+ )
906
+ ])
907
+ return { THREE, OrbitControls }
908
+ }
909
+
910
+ /**
911
+ * Resolves the scene-scale transform for one named preset.
912
+ * @param {string} preset Preset name.
913
+ * @param {{ coordinateSystem?: string } | null} [sceneDescription] Scene coordinate metadata.
914
+ * @returns {{ x: number, y: number, z: number }}
915
+ */
916
+ static resolveViewScale(preset, sceneDescription = null) {
917
+ return PcbScene3dViewScale.resolve(preset, sceneDescription)
918
+ }
919
+
920
+ /**
921
+ * Resolves the picked selection record from raycast intersections.
922
+ * @param {{ object?: any }[]} intersections
923
+ * @returns {{ designator?: string, sourceType?: string } | null}
924
+ */
925
+ static #resolveSelectionFromIntersections(intersections) {
926
+ for (const intersection of intersections) {
927
+ const selection = PcbScene3dRuntime.#resolveSelectionFromObject(
928
+ intersection?.object
929
+ )
930
+ if (selection) {
931
+ return selection
932
+ }
933
+ }
934
+
935
+ return null
936
+ }
937
+
938
+ /**
939
+ * Resolves one selection payload by walking up a picked object tree.
940
+ * @param {any} object
941
+ * @returns {{ designator?: string, sourceType?: string } | null}
942
+ */
943
+ static #resolveSelectionFromObject(object) {
944
+ let current = object
945
+
946
+ while (current) {
947
+ const selection = current?.userData?.scene3dSelection
948
+ if (selection) {
949
+ return selection
950
+ }
951
+
952
+ current = current.parent || null
953
+ }
954
+
955
+ return null
956
+ }
957
+
958
+ /** @returns {number} */
959
+ static #resolveSelectionHighlightColor() {
960
+ return 0x14c5e6
961
+ }
962
+
963
+ /**
964
+ * Resolves the drag travel between two pointer positions.
965
+ * @param {{ x: number, y: number }} start
966
+ * @param {{ x: number, y: number }} end
967
+ * @returns {number}
968
+ */
969
+ static #resolvePointerTravel(start, end) {
970
+ const dx = Number(end?.x || 0) - Number(start?.x || 0)
971
+ const dy = Number(end?.y || 0) - Number(start?.y || 0)
972
+ return Math.hypot(dx, dy)
973
+ }
974
+
975
+ /**
976
+ * Resolves a simple body color by package family.
977
+ * @param {string} family
978
+ * @returns {number}
979
+ */
980
+ static #resolveBodyColor(family) {
981
+ if (family === 'radial-capacitor') {
982
+ return 0xa60f10
983
+ }
984
+ if (family === 'connector-block') {
985
+ return 0xd5d6da
986
+ }
987
+ if (family === 'test-point') {
988
+ return 0x0ea5a8
989
+ }
990
+ if (family === 'chip') {
991
+ return 0xf5f5ef
992
+ }
993
+
994
+ return 0x232428
995
+ }
996
+ }