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.
Files changed (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. 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
+ }