altium-toolkit 0.1.0
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 +20 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +22 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/GPL-3.0-or-later.txt +232 -0
- package/NOTICE.md +32 -0
- package/README.md +116 -0
- package/docs/api.md +73 -0
- package/docs/model-format.md +36 -0
- package/docs/testing.md +25 -0
- package/examples/README.md +47 -0
- package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
- package/examples/arduino-uno/SvgViewportController.mjs +306 -0
- package/examples/arduino-uno/example.mjs +480 -0
- package/examples/arduino-uno/index.html +163 -0
- package/examples/arduino-uno/styles.css +552 -0
- package/examples/server.mjs +212 -0
- package/package.json +53 -0
- package/spec/library-scope.md +32 -0
- package/src/core/BinaryReader.mjs +127 -0
- package/src/core/altium/AltiumLayoutParser.mjs +485 -0
- package/src/core/altium/AltiumParser.mjs +1007 -0
- package/src/core/altium/AsciiRecordParser.mjs +151 -0
- package/src/core/altium/ParserUtils.mjs +173 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
- package/src/core/altium/PcbModelParser.mjs +336 -0
- package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
- package/src/core/altium/PcbStreamExtractor.mjs +210 -0
- package/src/core/altium/PrintableTextDecoder.mjs +156 -0
- package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
- package/src/core/altium/SchematicImageParser.mjs +173 -0
- package/src/core/altium/SchematicJunctionParser.mjs +43 -0
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
- package/src/core/altium/SchematicPinParser.mjs +767 -0
- package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
- package/src/core/altium/SchematicSheetParser.mjs +241 -0
- package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
- package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
- package/src/core/altium/SchematicTextParser.mjs +708 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
- package/src/core/ole/OleCompoundDocument.mjs +439 -0
- package/src/core/ole/OleConstants.mjs +64 -0
- package/src/core/ole/OleDirectoryEntry.mjs +95 -0
- package/src/index.mjs +7 -0
- package/src/parser.mjs +21 -0
- package/src/renderers.mjs +15 -0
- package/src/scene3d.mjs +9 -0
- package/src/styles/altium-renderers.css +358 -0
- package/src/ui/BomTableRenderer.mjs +46 -0
- package/src/ui/PcbArcUtils.mjs +189 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
- package/src/ui/PcbScene3dBuilder.mjs +742 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
- package/src/ui/PcbScene3dPackages.mjs +137 -0
- package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
- package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
- package/src/ui/PcbSvgRenderer.mjs +906 -0
- package/src/ui/SchematicColorResolver.mjs +132 -0
- package/src/ui/SchematicContentLayout.mjs +661 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
- package/src/ui/SchematicImageRenderer.mjs +135 -0
- package/src/ui/SchematicJunctionRenderer.mjs +381 -0
- package/src/ui/SchematicNoteRenderer.mjs +427 -0
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
- package/src/ui/SchematicPortRenderer.mjs +558 -0
- package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
- package/src/ui/SchematicRegionRenderer.mjs +94 -0
- package/src/ui/SchematicShapeRenderer.mjs +398 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
- package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
- package/src/ui/SchematicSvgRenderer.mjs +756 -0
- package/src/ui/SchematicSvgUtils.mjs +182 -0
- package/src/ui/SchematicTypography.mjs +204 -0
- package/src/workers/altium-parser.worker.mjs +29 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import * as THREE from 'three'
|
|
6
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
|
7
|
+
import { PcbScene3dBuilder } from '../../src/index.mjs'
|
|
8
|
+
|
|
9
|
+
const MAX_COPPER_MESHES = 420
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Mounts a lightweight interactive Three.js PCB preview for the example page.
|
|
13
|
+
*/
|
|
14
|
+
export class PcbThreeSceneRenderer {
|
|
15
|
+
#rootNode
|
|
16
|
+
#mountNode
|
|
17
|
+
#documentModel
|
|
18
|
+
#sceneDescription
|
|
19
|
+
#renderer = null
|
|
20
|
+
#scene = null
|
|
21
|
+
#camera = null
|
|
22
|
+
#controls = null
|
|
23
|
+
#resizeObserver = null
|
|
24
|
+
#animationFrame = 0
|
|
25
|
+
#listeners = []
|
|
26
|
+
#groups = new Map()
|
|
27
|
+
#isDisposed = false
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates and starts an interactive PCB scene in an existing shell.
|
|
31
|
+
* @param {HTMLElement} rootNode
|
|
32
|
+
* @param {{ pcb?: any }} documentModel
|
|
33
|
+
* @returns {PcbThreeSceneRenderer}
|
|
34
|
+
*/
|
|
35
|
+
static renderInto(rootNode, documentModel) {
|
|
36
|
+
const renderer = new PcbThreeSceneRenderer(rootNode, documentModel)
|
|
37
|
+
renderer.start()
|
|
38
|
+
return renderer
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a Three.js renderer controller.
|
|
43
|
+
* @param {HTMLElement} rootNode
|
|
44
|
+
* @param {{ pcb?: any }} documentModel
|
|
45
|
+
*/
|
|
46
|
+
constructor(rootNode, documentModel) {
|
|
47
|
+
this.#rootNode = rootNode
|
|
48
|
+
this.#mountNode = rootNode.querySelector(
|
|
49
|
+
'[data-three-scene-3d-viewport]'
|
|
50
|
+
)
|
|
51
|
+
this.#documentModel = documentModel
|
|
52
|
+
this.#sceneDescription = PcbScene3dBuilder.build(documentModel)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Starts the Three.js scene.
|
|
57
|
+
* @returns {void}
|
|
58
|
+
*/
|
|
59
|
+
start() {
|
|
60
|
+
if (!this.#documentModel?.pcb || !this.#mountNode) {
|
|
61
|
+
this.#setDiagnostics('3D preview is available for PCB documents.')
|
|
62
|
+
this.#setLoading(false)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.#createRenderer()
|
|
67
|
+
this.#createScene()
|
|
68
|
+
this.#createControls()
|
|
69
|
+
this.#bindUiControls()
|
|
70
|
+
this.#observeSize()
|
|
71
|
+
this.setPreset('isometric')
|
|
72
|
+
this.#setDiagnostics(this.#formatSceneSummary())
|
|
73
|
+
this.#setLoading(false)
|
|
74
|
+
this.#renderLoop()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Applies a named camera preset.
|
|
79
|
+
* @param {string} preset
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
setPreset(preset) {
|
|
83
|
+
if (!this.#camera || !this.#controls) return
|
|
84
|
+
|
|
85
|
+
const normalizedPreset = String(preset || 'isometric').toLowerCase()
|
|
86
|
+
const radius = this.#resolveCameraRadius(normalizedPreset)
|
|
87
|
+
const target = new THREE.Vector3(0, 0, 0)
|
|
88
|
+
let position = new THREE.Vector3(radius, -radius, radius * 0.65)
|
|
89
|
+
let up = new THREE.Vector3(0, 0, 1)
|
|
90
|
+
|
|
91
|
+
if (normalizedPreset === 'top') {
|
|
92
|
+
position = new THREE.Vector3(0, 0, radius)
|
|
93
|
+
up = new THREE.Vector3(0, 1, 0)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (normalizedPreset === 'bottom') {
|
|
97
|
+
position = new THREE.Vector3(0, 0, -radius)
|
|
98
|
+
up = new THREE.Vector3(0, -1, 0)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.#camera.up.copy(up)
|
|
102
|
+
this.#camera.position.copy(position)
|
|
103
|
+
this.#controls.target.copy(target)
|
|
104
|
+
this.#camera.lookAt(target)
|
|
105
|
+
this.#controls.update()
|
|
106
|
+
this.#syncPresetButtons(normalizedPreset)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Shows or hides a scene detail group.
|
|
111
|
+
* @param {string} groupName
|
|
112
|
+
* @param {boolean} isVisible
|
|
113
|
+
* @returns {void}
|
|
114
|
+
*/
|
|
115
|
+
setGroupVisibility(groupName, isVisible) {
|
|
116
|
+
const group = this.#groups.get(groupName)
|
|
117
|
+
if (!group) return
|
|
118
|
+
|
|
119
|
+
group.visible = isVisible
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Releases browser and Three.js resources.
|
|
124
|
+
* @returns {void}
|
|
125
|
+
*/
|
|
126
|
+
dispose() {
|
|
127
|
+
this.#isDisposed = true
|
|
128
|
+
if (this.#animationFrame) cancelAnimationFrame(this.#animationFrame)
|
|
129
|
+
|
|
130
|
+
for (const { node, type, listener } of this.#listeners) {
|
|
131
|
+
node.removeEventListener(type, listener)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.#listeners = []
|
|
135
|
+
this.#resizeObserver?.disconnect()
|
|
136
|
+
this.#controls?.dispose()
|
|
137
|
+
this.#disposeSceneGraph()
|
|
138
|
+
this.#renderer?.dispose()
|
|
139
|
+
this.#renderer?.domElement?.remove()
|
|
140
|
+
|
|
141
|
+
this.#renderer = null
|
|
142
|
+
this.#scene = null
|
|
143
|
+
this.#camera = null
|
|
144
|
+
this.#controls = null
|
|
145
|
+
this.#resizeObserver = null
|
|
146
|
+
this.#groups.clear()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Creates the WebGL renderer and camera.
|
|
151
|
+
* @returns {void}
|
|
152
|
+
*/
|
|
153
|
+
#createRenderer() {
|
|
154
|
+
const { width, height } = this.#resolveViewportSize()
|
|
155
|
+
const board = this.#sceneDescription.board
|
|
156
|
+
const cameraFar = Math.max(board.widthMil, board.heightMil, 1000) * 8
|
|
157
|
+
|
|
158
|
+
this.#renderer = new THREE.WebGLRenderer({
|
|
159
|
+
antialias: true,
|
|
160
|
+
alpha: true,
|
|
161
|
+
powerPreference: 'high-performance'
|
|
162
|
+
})
|
|
163
|
+
this.#renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
|
164
|
+
this.#renderer.setSize(width, height, false)
|
|
165
|
+
this.#renderer.setClearColor(0xf6f1e8, 1)
|
|
166
|
+
this.#renderer.domElement.className = 'scene-3d__canvas'
|
|
167
|
+
this.#renderer.domElement.setAttribute(
|
|
168
|
+
'aria-label',
|
|
169
|
+
'Interactive PCB 3D canvas'
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
this.#camera = new THREE.PerspectiveCamera(
|
|
173
|
+
38,
|
|
174
|
+
width / height,
|
|
175
|
+
1,
|
|
176
|
+
cameraFar
|
|
177
|
+
)
|
|
178
|
+
this.#camera.up.set(0, 0, 1)
|
|
179
|
+
this.#mountNode.replaceChildren(this.#renderer.domElement)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Creates lights and scene meshes.
|
|
184
|
+
* @returns {void}
|
|
185
|
+
*/
|
|
186
|
+
#createScene() {
|
|
187
|
+
const board = this.#sceneDescription.board
|
|
188
|
+
const boardSpan = Math.max(board.widthMil, board.heightMil, 1000)
|
|
189
|
+
|
|
190
|
+
this.#scene = new THREE.Scene()
|
|
191
|
+
this.#scene.fog = new THREE.Fog(
|
|
192
|
+
0xf6f1e8,
|
|
193
|
+
boardSpan * 2.2,
|
|
194
|
+
boardSpan * 8
|
|
195
|
+
)
|
|
196
|
+
this.#scene.add(new THREE.AmbientLight(0xffffff, 1.7))
|
|
197
|
+
|
|
198
|
+
const keyLight = new THREE.DirectionalLight(0xffffff, 1.6)
|
|
199
|
+
keyLight.position.set(1600, -2600, 4200)
|
|
200
|
+
this.#scene.add(keyLight)
|
|
201
|
+
|
|
202
|
+
const fillLight = new THREE.DirectionalLight(0xd7ebff, 0.85)
|
|
203
|
+
fillLight.position.set(-3000, 1800, 2600)
|
|
204
|
+
this.#scene.add(fillLight)
|
|
205
|
+
|
|
206
|
+
const rootGroup = new THREE.Group()
|
|
207
|
+
rootGroup.add(this.#buildBoardGroup())
|
|
208
|
+
rootGroup.add(this.#buildComponentGroup())
|
|
209
|
+
rootGroup.add(this.#buildCopperGroup())
|
|
210
|
+
this.#scene.add(rootGroup)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Creates OrbitControls for browser interaction.
|
|
215
|
+
* @returns {void}
|
|
216
|
+
*/
|
|
217
|
+
#createControls() {
|
|
218
|
+
this.#controls = new OrbitControls(
|
|
219
|
+
this.#camera,
|
|
220
|
+
this.#renderer.domElement
|
|
221
|
+
)
|
|
222
|
+
this.#controls.enableDamping = true
|
|
223
|
+
this.#controls.dampingFactor = 0.08
|
|
224
|
+
this.#controls.screenSpacePanning = true
|
|
225
|
+
this.#controls.minDistance = 220
|
|
226
|
+
this.#controls.maxDistance =
|
|
227
|
+
Math.max(
|
|
228
|
+
this.#sceneDescription.board.widthMil,
|
|
229
|
+
this.#sceneDescription.board.heightMil,
|
|
230
|
+
900
|
|
231
|
+
) * 4
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Binds preset buttons and visibility toggles in the scene shell.
|
|
236
|
+
* @returns {void}
|
|
237
|
+
*/
|
|
238
|
+
#bindUiControls() {
|
|
239
|
+
const presetButtons = [
|
|
240
|
+
...this.#rootNode.querySelectorAll('[data-three-scene-3d-preset]')
|
|
241
|
+
]
|
|
242
|
+
const toggles = [
|
|
243
|
+
...this.#rootNode.querySelectorAll('[data-three-scene-3d-toggle]')
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
for (const button of presetButtons) {
|
|
247
|
+
const listener = () =>
|
|
248
|
+
this.setPreset(
|
|
249
|
+
button.getAttribute('data-three-scene-3d-preset')
|
|
250
|
+
)
|
|
251
|
+
button.addEventListener('click', listener)
|
|
252
|
+
this.#listeners.push({ node: button, type: 'click', listener })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const toggle of toggles) {
|
|
256
|
+
const listener = () => {
|
|
257
|
+
this.setGroupVisibility(
|
|
258
|
+
toggle.getAttribute('data-three-scene-3d-toggle'),
|
|
259
|
+
toggle.checked
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
toggle.addEventListener('change', listener)
|
|
263
|
+
this.#listeners.push({ node: toggle, type: 'change', listener })
|
|
264
|
+
this.setGroupVisibility(
|
|
265
|
+
toggle.getAttribute('data-three-scene-3d-toggle'),
|
|
266
|
+
toggle.checked
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolves the camera distance needed to keep the board framed.
|
|
273
|
+
* @param {string} preset
|
|
274
|
+
* @returns {number}
|
|
275
|
+
*/
|
|
276
|
+
#resolveCameraRadius(preset) {
|
|
277
|
+
const board = this.#sceneDescription.board
|
|
278
|
+
const verticalFov = THREE.MathUtils.degToRad(this.#camera.fov)
|
|
279
|
+
const aspect = Math.max(Number(this.#camera.aspect || 1), 0.1)
|
|
280
|
+
const horizontalFit =
|
|
281
|
+
board.widthMil / (2 * Math.tan(verticalFov / 2) * aspect)
|
|
282
|
+
const verticalFit = board.heightMil / (2 * Math.tan(verticalFov / 2))
|
|
283
|
+
const flatFit = Math.max(horizontalFit, verticalFit, 900) * 1.16
|
|
284
|
+
|
|
285
|
+
return preset === 'isometric' ? flatFit * 1.12 : flatFit
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Watches the mount size and keeps the canvas sharp.
|
|
290
|
+
* @returns {void}
|
|
291
|
+
*/
|
|
292
|
+
#observeSize() {
|
|
293
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
294
|
+
this.#resizeObserver = new ResizeObserver(() => this.#resize())
|
|
295
|
+
this.#resizeObserver.observe(this.#mountNode)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
window.addEventListener('resize', this.#resize)
|
|
299
|
+
this.#listeners.push({
|
|
300
|
+
node: window,
|
|
301
|
+
type: 'resize',
|
|
302
|
+
listener: this.#resize
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Builds the board shell and outline.
|
|
308
|
+
* @returns {THREE.Group}
|
|
309
|
+
*/
|
|
310
|
+
#buildBoardGroup() {
|
|
311
|
+
const board = this.#sceneDescription.board
|
|
312
|
+
const boardGroup = new THREE.Group()
|
|
313
|
+
const width = Math.max(board.widthMil, 80)
|
|
314
|
+
const height = Math.max(board.heightMil, 80)
|
|
315
|
+
const thickness = Math.max(board.thicknessMil, 24)
|
|
316
|
+
const boardMesh = new THREE.Mesh(
|
|
317
|
+
new THREE.BoxGeometry(width, height, thickness),
|
|
318
|
+
new THREE.MeshStandardMaterial({
|
|
319
|
+
color: 0x1f7a68,
|
|
320
|
+
roughness: 0.72,
|
|
321
|
+
metalness: 0.02
|
|
322
|
+
})
|
|
323
|
+
)
|
|
324
|
+
const edgeLines = new THREE.LineSegments(
|
|
325
|
+
new THREE.EdgesGeometry(boardMesh.geometry),
|
|
326
|
+
new THREE.LineBasicMaterial({
|
|
327
|
+
color: 0x0e423a,
|
|
328
|
+
transparent: true,
|
|
329
|
+
opacity: 0.46
|
|
330
|
+
})
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
boardGroup.add(boardMesh)
|
|
334
|
+
boardGroup.add(edgeLines)
|
|
335
|
+
this.#groups.set('board', boardGroup)
|
|
336
|
+
|
|
337
|
+
return boardGroup
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Builds simplified component package bodies.
|
|
342
|
+
* @returns {THREE.Group}
|
|
343
|
+
*/
|
|
344
|
+
#buildComponentGroup() {
|
|
345
|
+
const componentGroup = new THREE.Group()
|
|
346
|
+
|
|
347
|
+
for (const component of this.#sceneDescription.components) {
|
|
348
|
+
const body = component.body || {}
|
|
349
|
+
const size = body.sizeMil || {}
|
|
350
|
+
const width = Math.max(Number(size.width || 0), 36)
|
|
351
|
+
const depth = Math.max(Number(size.depth || 0), 36)
|
|
352
|
+
const height = Math.max(Number(size.height || 0), 24)
|
|
353
|
+
const mesh = new THREE.Mesh(
|
|
354
|
+
new THREE.BoxGeometry(width, depth, height),
|
|
355
|
+
new THREE.MeshStandardMaterial({
|
|
356
|
+
color: this.#resolveComponentColor(body.family),
|
|
357
|
+
roughness: 0.62,
|
|
358
|
+
metalness: 0.06
|
|
359
|
+
})
|
|
360
|
+
)
|
|
361
|
+
const position = component.positionMil || {}
|
|
362
|
+
|
|
363
|
+
mesh.position.set(
|
|
364
|
+
Number(position.x || 0),
|
|
365
|
+
Number(position.y || 0),
|
|
366
|
+
Number(position.z || 0)
|
|
367
|
+
)
|
|
368
|
+
mesh.rotation.z = THREE.MathUtils.degToRad(
|
|
369
|
+
Number(component.rotationDeg || 0)
|
|
370
|
+
)
|
|
371
|
+
mesh.userData = {
|
|
372
|
+
designator: component.designator,
|
|
373
|
+
family: body.family,
|
|
374
|
+
mountSide: component.mountSide
|
|
375
|
+
}
|
|
376
|
+
componentGroup.add(mesh)
|
|
377
|
+
componentGroup.add(this.#buildMeshEdges(mesh, 0x132127, 0.28))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
this.#groups.set('components', componentGroup)
|
|
381
|
+
|
|
382
|
+
return componentGroup
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Builds a capped set of top-side copper pad and track hints.
|
|
387
|
+
* @returns {THREE.Group}
|
|
388
|
+
*/
|
|
389
|
+
#buildCopperGroup() {
|
|
390
|
+
const copperGroup = new THREE.Group()
|
|
391
|
+
const board = this.#sceneDescription.board
|
|
392
|
+
const copperMaterial = new THREE.MeshStandardMaterial({
|
|
393
|
+
color: 0xc35f35,
|
|
394
|
+
roughness: 0.48,
|
|
395
|
+
metalness: 0.18
|
|
396
|
+
})
|
|
397
|
+
let meshCount = 0
|
|
398
|
+
|
|
399
|
+
for (const pad of this.#sceneDescription.detail.pads || []) {
|
|
400
|
+
if (meshCount >= MAX_COPPER_MESHES) break
|
|
401
|
+
|
|
402
|
+
const padSize = this.#resolvePadSize(pad)
|
|
403
|
+
const padMesh = new THREE.Mesh(
|
|
404
|
+
new THREE.BoxGeometry(padSize.width, padSize.height, 5),
|
|
405
|
+
copperMaterial
|
|
406
|
+
)
|
|
407
|
+
padMesh.position.set(
|
|
408
|
+
Number(pad.x || 0) - board.centerX,
|
|
409
|
+
Number(pad.y || 0) - board.centerY,
|
|
410
|
+
board.thicknessMil / 2 + 3
|
|
411
|
+
)
|
|
412
|
+
copperGroup.add(padMesh)
|
|
413
|
+
meshCount += 1
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const track of this.#sceneDescription.detail.tracks || []) {
|
|
417
|
+
if (meshCount >= MAX_COPPER_MESHES) break
|
|
418
|
+
|
|
419
|
+
const trackMesh = this.#buildTrackMesh(track, copperMaterial)
|
|
420
|
+
if (!trackMesh) continue
|
|
421
|
+
|
|
422
|
+
copperGroup.add(trackMesh)
|
|
423
|
+
meshCount += 1
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
this.#groups.set('copper', copperGroup)
|
|
427
|
+
|
|
428
|
+
return copperGroup
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Builds a simple track rectangle between two endpoints.
|
|
433
|
+
* @param {Record<string, number>} track
|
|
434
|
+
* @param {THREE.Material} material
|
|
435
|
+
* @returns {THREE.Mesh | null}
|
|
436
|
+
*/
|
|
437
|
+
#buildTrackMesh(track, material) {
|
|
438
|
+
const board = this.#sceneDescription.board
|
|
439
|
+
const x1 = Number(track.x1 ?? track.xStart ?? track.startX ?? NaN)
|
|
440
|
+
const y1 = Number(track.y1 ?? track.yStart ?? track.startY ?? NaN)
|
|
441
|
+
const x2 = Number(track.x2 ?? track.xEnd ?? track.endX ?? NaN)
|
|
442
|
+
const y2 = Number(track.y2 ?? track.yEnd ?? track.endY ?? NaN)
|
|
443
|
+
if (![x1, y1, x2, y2].every(Number.isFinite)) return null
|
|
444
|
+
|
|
445
|
+
const length = Math.hypot(x2 - x1, y2 - y1)
|
|
446
|
+
if (length <= 0) return null
|
|
447
|
+
|
|
448
|
+
const width = Math.max(Number(track.width || track.lineWidth || 8), 5)
|
|
449
|
+
const mesh = new THREE.Mesh(
|
|
450
|
+
new THREE.BoxGeometry(length, width, 4),
|
|
451
|
+
material
|
|
452
|
+
)
|
|
453
|
+
mesh.position.set(
|
|
454
|
+
(x1 + x2) / 2 - board.centerX,
|
|
455
|
+
(y1 + y2) / 2 - board.centerY,
|
|
456
|
+
board.thicknessMil / 2 + 4
|
|
457
|
+
)
|
|
458
|
+
mesh.rotation.z = Math.atan2(y2 - y1, x2 - x1)
|
|
459
|
+
|
|
460
|
+
return mesh
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Creates edge line segments for a mesh.
|
|
465
|
+
* @param {THREE.Mesh} mesh
|
|
466
|
+
* @param {number} color
|
|
467
|
+
* @param {number} opacity
|
|
468
|
+
* @returns {THREE.LineSegments}
|
|
469
|
+
*/
|
|
470
|
+
#buildMeshEdges(mesh, color, opacity) {
|
|
471
|
+
const edgeLines = new THREE.LineSegments(
|
|
472
|
+
new THREE.EdgesGeometry(mesh.geometry),
|
|
473
|
+
new THREE.LineBasicMaterial({
|
|
474
|
+
color,
|
|
475
|
+
transparent: true,
|
|
476
|
+
opacity
|
|
477
|
+
})
|
|
478
|
+
)
|
|
479
|
+
edgeLines.position.copy(mesh.position)
|
|
480
|
+
edgeLines.rotation.copy(mesh.rotation)
|
|
481
|
+
|
|
482
|
+
return edgeLines
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Resizes the renderer to the viewport.
|
|
487
|
+
* @returns {void}
|
|
488
|
+
*/
|
|
489
|
+
#resize = () => {
|
|
490
|
+
if (!this.#renderer || !this.#camera || !this.#mountNode) return
|
|
491
|
+
|
|
492
|
+
const { width, height } = this.#resolveViewportSize()
|
|
493
|
+
this.#camera.aspect = width / height
|
|
494
|
+
this.#camera.updateProjectionMatrix()
|
|
495
|
+
this.#renderer.setSize(width, height, false)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Renders the scene continuously for smooth controls.
|
|
500
|
+
* @returns {void}
|
|
501
|
+
*/
|
|
502
|
+
#renderLoop = () => {
|
|
503
|
+
if (this.#isDisposed) return
|
|
504
|
+
|
|
505
|
+
this.#controls?.update()
|
|
506
|
+
this.#renderer?.render(this.#scene, this.#camera)
|
|
507
|
+
this.#animationFrame = requestAnimationFrame(this.#renderLoop)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Resolves the current viewport size.
|
|
512
|
+
* @returns {{ width: number, height: number }}
|
|
513
|
+
*/
|
|
514
|
+
#resolveViewportSize() {
|
|
515
|
+
const bounds = this.#mountNode?.getBoundingClientRect?.() || {}
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
width: Math.max(Math.round(bounds.width || 900), 320),
|
|
519
|
+
height: Math.max(Math.round(bounds.height || 560), 280)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Resolves a pad footprint size.
|
|
525
|
+
* @param {{ sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number }} pad
|
|
526
|
+
* @returns {{ width: number, height: number }}
|
|
527
|
+
*/
|
|
528
|
+
#resolvePadSize(pad) {
|
|
529
|
+
return {
|
|
530
|
+
width: Math.max(
|
|
531
|
+
Number(pad.sizeTopX || pad.sizeMidX || pad.sizeBottomX || 34),
|
|
532
|
+
14
|
|
533
|
+
),
|
|
534
|
+
height: Math.max(
|
|
535
|
+
Number(pad.sizeTopY || pad.sizeMidY || pad.sizeBottomY || 34),
|
|
536
|
+
14
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Resolves a component material color by package family.
|
|
543
|
+
* @param {string | undefined} family
|
|
544
|
+
* @returns {number}
|
|
545
|
+
*/
|
|
546
|
+
#resolveComponentColor(family) {
|
|
547
|
+
const normalizedFamily = String(family || '').toLowerCase()
|
|
548
|
+
if (normalizedFamily.includes('capacitor')) return 0x2a6fbb
|
|
549
|
+
if (normalizedFamily.includes('connector')) return 0x3b4650
|
|
550
|
+
if (normalizedFamily.includes('resistor')) return 0xd4b25f
|
|
551
|
+
if (normalizedFamily.includes('ic')) return 0x202832
|
|
552
|
+
if (normalizedFamily.includes('diode')) return 0x642f93
|
|
553
|
+
|
|
554
|
+
return 0x687782
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Writes scene diagnostics.
|
|
559
|
+
* @param {string} message
|
|
560
|
+
* @returns {void}
|
|
561
|
+
*/
|
|
562
|
+
#setDiagnostics(message) {
|
|
563
|
+
const diagnosticsNode = this.#rootNode.querySelector(
|
|
564
|
+
'[data-three-scene-3d-diagnostics]'
|
|
565
|
+
)
|
|
566
|
+
if (diagnosticsNode) diagnosticsNode.textContent = message
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Shows or hides the loading state.
|
|
571
|
+
* @param {boolean} isLoading
|
|
572
|
+
* @returns {void}
|
|
573
|
+
*/
|
|
574
|
+
#setLoading(isLoading) {
|
|
575
|
+
const loadingNode = this.#rootNode.querySelector(
|
|
576
|
+
'[data-three-scene-3d-loading]'
|
|
577
|
+
)
|
|
578
|
+
if (loadingNode) loadingNode.hidden = !isLoading
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Updates pressed state on camera preset buttons.
|
|
583
|
+
* @param {string} activePreset
|
|
584
|
+
* @returns {void}
|
|
585
|
+
*/
|
|
586
|
+
#syncPresetButtons(activePreset) {
|
|
587
|
+
const presetButtons = [
|
|
588
|
+
...this.#rootNode.querySelectorAll('[data-three-scene-3d-preset]')
|
|
589
|
+
]
|
|
590
|
+
|
|
591
|
+
for (const button of presetButtons) {
|
|
592
|
+
const isActive =
|
|
593
|
+
button.getAttribute('data-three-scene-3d-preset') ===
|
|
594
|
+
activePreset
|
|
595
|
+
button.classList.toggle('is-active', isActive)
|
|
596
|
+
button.setAttribute('aria-pressed', String(isActive))
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Formats a compact scene summary for the diagnostics region.
|
|
602
|
+
* @returns {string}
|
|
603
|
+
*/
|
|
604
|
+
#formatSceneSummary() {
|
|
605
|
+
const board = this.#sceneDescription.board
|
|
606
|
+
const width = Math.round(board.widthMil)
|
|
607
|
+
const height = Math.round(board.heightMil)
|
|
608
|
+
const components = this.#sceneDescription.components.length
|
|
609
|
+
|
|
610
|
+
return (
|
|
611
|
+
width +
|
|
612
|
+
' x ' +
|
|
613
|
+
height +
|
|
614
|
+
' mil PCB with ' +
|
|
615
|
+
components +
|
|
616
|
+
' rendered package bodies.'
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Releases scene graph geometries and materials.
|
|
622
|
+
* @returns {void}
|
|
623
|
+
*/
|
|
624
|
+
#disposeSceneGraph() {
|
|
625
|
+
this.#scene?.traverse((object) => {
|
|
626
|
+
object.geometry?.dispose?.()
|
|
627
|
+
const material = object.material
|
|
628
|
+
if (Array.isArray(material)) {
|
|
629
|
+
material.forEach((entry) => entry.dispose?.())
|
|
630
|
+
} else {
|
|
631
|
+
material?.dispose?.()
|
|
632
|
+
}
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
}
|