pcb-scene3d-viewer 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +15 -0
- package/CONTRIBUTING.md +14 -0
- package/LICENSE +19 -0
- package/LICENSES/AGPL-3.0-or-later.txt +235 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/LGPL-2.1-or-later.txt +176 -0
- package/LICENSES/LicenseRef-PolyForm-Noncommercial-1.0.0.txt +131 -0
- package/NOTICE.md +36 -0
- package/README.md +128 -0
- package/REUSE.toml +16 -0
- package/docs/api.md +148 -0
- package/docs/circuitjson.md +190 -0
- package/docs/model-format.md +117 -0
- package/docs/testing.md +23 -0
- package/package.json +65 -0
- package/spec/library-scope.md +36 -0
- package/src/PcbModelArchiveExporter.mjs +320 -0
- package/src/PcbScene3dArcUtils.mjs +27 -0
- package/src/PcbScene3dBoardAssemblyPlacement.mjs +36 -0
- package/src/PcbScene3dBoardAssemblyPresentation.mjs +859 -0
- package/src/PcbScene3dBoardEdgeCutoutBuilder.mjs +537 -0
- package/src/PcbScene3dBoardMaterialPalette.mjs +40 -0
- package/src/PcbScene3dBoardShapeFactory.mjs +895 -0
- package/src/PcbScene3dBoardSolderMaskFactory.mjs +613 -0
- package/src/PcbScene3dCameraRig.mjs +168 -0
- package/src/PcbScene3dCircuitJsonAdapter.mjs +545 -0
- package/src/PcbScene3dController.mjs +956 -0
- package/src/PcbScene3dCopperDetailFilter.mjs +490 -0
- package/src/PcbScene3dCopperFactory.mjs +559 -0
- package/src/PcbScene3dCopperTextFactory.mjs +534 -0
- package/src/PcbScene3dCutoutGeometryFilter.mjs +873 -0
- package/src/PcbScene3dDetailCoordinateNormalizer.mjs +65 -0
- package/src/PcbScene3dDrillCutoutFilter.mjs +224 -0
- package/src/PcbScene3dDrillPathFactory.mjs +362 -0
- package/src/PcbScene3dDrillVoidFactory.mjs +268 -0
- package/src/PcbScene3dExternalModelLoadOrder.mjs +54 -0
- package/src/PcbScene3dExternalModels.mjs +968 -0
- package/src/PcbScene3dFallbackVisibility.mjs +82 -0
- package/src/PcbScene3dInteractionHints.mjs +56 -0
- package/src/PcbScene3dMountRig.mjs +53 -0
- package/src/PcbScene3dOutlineBuilder.mjs +210 -0
- package/src/PcbScene3dPadFactory.mjs +553 -0
- package/src/PcbScene3dPresetState.mjs +48 -0
- package/src/PcbScene3dRenderGroupVisibility.mjs +134 -0
- package/src/PcbScene3dRuntime.mjs +996 -0
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +99 -0
- package/src/PcbScene3dSelectionStyler.mjs +252 -0
- package/src/PcbScene3dShapePathFactory.mjs +220 -0
- package/src/PcbScene3dShellRenderer.mjs +131 -0
- package/src/PcbScene3dSilkscreenFactory.mjs +854 -0
- package/src/PcbScene3dSilkscreenStrokeWidthResolver.mjs +81 -0
- package/src/PcbScene3dStepLoader.mjs +611 -0
- package/src/PcbScene3dStrokeFont.mjs +671 -0
- package/src/PcbScene3dStrokeGeometryBuilder.mjs +322 -0
- package/src/PcbScene3dText.mjs +99 -0
- package/src/PcbScene3dTrueTypeTextFactory.mjs +885 -0
- package/src/PcbScene3dViaFactory.mjs +176 -0
- package/src/PcbScene3dViewCompensation.mjs +109 -0
- package/src/PcbScene3dViewScale.mjs +24 -0
- package/src/PcbScene3dViewportResize.mjs +35 -0
- package/src/PcbScene3dWorkerClient.mjs +123 -0
- package/src/index.mjs +1 -0
- package/src/scene3d.mjs +44 -0
- package/src/styles/scene3d.css +295 -0
|
@@ -0,0 +1,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
|
+
}
|