pcb-scene3d-viewer 1.1.21 → 1.1.31

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 (55) hide show
  1. package/README.md +3 -2
  2. package/docs/api.md +27 -1
  3. package/docs/circuitjson.md +63 -18
  4. package/docs/model-format.md +13 -2
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +2 -2
  7. package/src/PcbAssemblyBoardSubstrateBuilder.mjs +25 -5
  8. package/src/PcbAssemblyComponentMeshBuilder.mjs +762 -0
  9. package/src/PcbAssemblyFillGeometryResolver.mjs +579 -0
  10. package/src/PcbAssemblyFillRingNormalizer.mjs +209 -0
  11. package/src/PcbAssemblyGeometryBuilder.mjs +41 -301
  12. package/src/PcbAssemblyGltfModelMeshParser.mjs +912 -0
  13. package/src/PcbAssemblyGltfValidator.mjs +460 -0
  14. package/src/PcbAssemblyGltfWriter.mjs +801 -0
  15. package/src/PcbAssemblyMeshUtils.mjs +117 -0
  16. package/src/PcbAssemblyModelMeshLoader.mjs +18 -0
  17. package/src/PcbAssemblyPadMeshBuilder.mjs +394 -0
  18. package/src/PcbAssemblyStepWriter.mjs +24 -2
  19. package/src/PcbAssemblyTextModelMeshParser.mjs +602 -0
  20. package/src/PcbModelArchiveExporter.mjs +521 -7
  21. package/src/PcbScene3dCircuitJsonAdapter.mjs +409 -7
  22. package/src/PcbScene3dCircuitJsonModelTransform.mjs +232 -0
  23. package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +242 -0
  24. package/src/PcbScene3dComponentVisibility.mjs +100 -5
  25. package/src/PcbScene3dController.mjs +25 -55
  26. package/src/PcbScene3dCopperDetailFilter.mjs +86 -9
  27. package/src/PcbScene3dCopperDetailGroupBuilder.mjs +186 -15
  28. package/src/PcbScene3dCopperDrillCutoutBuilder.mjs +258 -0
  29. package/src/PcbScene3dCopperFactory.mjs +99 -85
  30. package/src/PcbScene3dCopperFillMeshBuilder.mjs +393 -0
  31. package/src/PcbScene3dCopperLayerFilter.mjs +32 -3
  32. package/src/PcbScene3dCutoutGeometryFilter.mjs +17 -14
  33. package/src/PcbScene3dExternalCompanionFallback.mjs +202 -0
  34. package/src/PcbScene3dExternalModelCenteringPolicy.mjs +21 -0
  35. package/src/PcbScene3dExternalModelOpacity.mjs +51 -0
  36. package/src/PcbScene3dExternalModelPlacementRepair.mjs +5 -2
  37. package/src/PcbScene3dExternalModelSourceOriginPolicy.mjs +41 -0
  38. package/src/PcbScene3dExternalModels.mjs +16 -7
  39. package/src/PcbScene3dFallbackBodyFactory.mjs +105 -0
  40. package/src/PcbScene3dFallbackVisibility.mjs +58 -1
  41. package/src/PcbScene3dMaskCoveredCopperSideGroupBuilder.mjs +68 -0
  42. package/src/PcbScene3dPadFactory.mjs +35 -2
  43. package/src/PcbScene3dRenderGroupVisibility.mjs +8 -1
  44. package/src/PcbScene3dRuntime.mjs +94 -100
  45. package/src/PcbScene3dRuntimeHelpers.mjs +39 -0
  46. package/src/PcbScene3dSelectionIndexBuilder.mjs +87 -0
  47. package/src/PcbScene3dSelectionInspectorRenderer.mjs +50 -11
  48. package/src/PcbScene3dSelectionMarkerFactory.mjs +333 -0
  49. package/src/PcbScene3dSelectionMarkerOverlay.mjs +70 -0
  50. package/src/PcbScene3dSelectionStyler.mjs +52 -2
  51. package/src/PcbScene3dStaticBodyFactory.mjs +263 -0
  52. package/src/PcbScene3dText.mjs +1 -0
  53. package/src/PcbScene3dTransparentMeshSplitter.mjs +623 -0
  54. package/src/PcbScene3dViaFactory.mjs +27 -7
  55. package/src/scene3d.mjs +3 -0
@@ -314,12 +314,12 @@ export class PcbScene3dPadFactory {
314
314
 
315
315
  /**
316
316
  * Returns true when one pad has visible copper on the requested face.
317
- * @param {{ sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number, hasTopSolderMaskOpening?: boolean, hasBottomSolderMaskOpening?: boolean }} pad
317
+ * @param {{ sizeTopX?: number, sizeTopY?: number, sizeMidX?: number, sizeMidY?: number, sizeBottomX?: number, sizeBottomY?: number, holeDiameter?: number, hasTopSolderMaskOpening?: boolean, hasBottomSolderMaskOpening?: boolean, hasTopPasteMaskOpening?: boolean, hasBottomPasteMaskOpening?: boolean }} pad
318
318
  * @param {'top' | 'bottom'} side
319
319
  * @returns {boolean}
320
320
  */
321
321
  static #hasVisibleSurface(pad, side) {
322
- const maskOpening = PcbScene3dPadFactory.#resolveSolderMaskOpening(
322
+ const maskOpening = PcbScene3dPadFactory.#resolveSurfaceOpening(
323
323
  pad,
324
324
  side
325
325
  )
@@ -350,6 +350,39 @@ export class PcbScene3dPadFactory {
350
350
  )
351
351
  }
352
352
 
353
+ /**
354
+ * Resolves an explicit side-specific surface opening flag.
355
+ * @param {{ hasTopSolderMaskOpening?: boolean, hasBottomSolderMaskOpening?: boolean, hasTopPasteMaskOpening?: boolean, hasBottomPasteMaskOpening?: boolean }} pad
356
+ * @param {'top' | 'bottom'} side
357
+ * @returns {boolean | null}
358
+ */
359
+ static #resolveSurfaceOpening(pad, side) {
360
+ const solderMaskOpening =
361
+ PcbScene3dPadFactory.#resolveSolderMaskOpening(pad, side)
362
+ if (solderMaskOpening !== null) {
363
+ return solderMaskOpening
364
+ }
365
+
366
+ const pasteFieldName =
367
+ side === 'bottom'
368
+ ? 'hasBottomPasteMaskOpening'
369
+ : 'hasTopPasteMaskOpening'
370
+ const alternatePasteFieldName =
371
+ side === 'bottom'
372
+ ? 'hasTopPasteMaskOpening'
373
+ : 'hasBottomPasteMaskOpening'
374
+
375
+ if (typeof pad?.[pasteFieldName] === 'boolean') {
376
+ if (pad[pasteFieldName]) {
377
+ return true
378
+ }
379
+
380
+ return pad?.[alternatePasteFieldName] === true ? false : null
381
+ }
382
+
383
+ return null
384
+ }
385
+
353
386
  /**
354
387
  * Resolves an explicit side-specific solder-mask opening when available.
355
388
  * @param {{ hasTopSolderMaskOpening?: boolean, hasBottomSolderMaskOpening?: boolean }} pad
@@ -37,9 +37,16 @@ export class PcbScene3dRenderGroupVisibility {
37
37
  state?.groups?.get('copper'),
38
38
  boardAssemblyActive
39
39
  )
40
+ const showFallbackBodies =
41
+ !boardAssemblyActive &&
42
+ (Boolean(state?.toggles?.['fallback-bodies']) ||
43
+ PcbScene3dFallbackVisibility.hasVisibleExternalCompanion(
44
+ state?.fallbackBodyRoots,
45
+ state?.toggles
46
+ ))
40
47
  PcbScene3dRenderGroupVisibility.#setVisible(
41
48
  state?.groups?.get('fallback-bodies'),
42
- Boolean(state?.toggles?.['fallback-bodies']) && !boardAssemblyActive
49
+ showFallbackBodies
43
50
  )
44
51
 
45
52
  PcbScene3dFallbackVisibility.applyVisibility(
@@ -1,27 +1,31 @@
1
1
  import { PcbScene3dBoardSolderMaskFactory } from './PcbScene3dBoardSolderMaskFactory.mjs'
2
- import { PcbScene3dBodyColor } from './PcbScene3dBodyColor.mjs'
3
2
  import { PcbScene3dCameraRig } from './PcbScene3dCameraRig.mjs'
4
3
  import { PcbScene3dCircuitJsonAdapter } from './PcbScene3dCircuitJsonAdapter.mjs'
5
4
  import { PcbScene3dComponentVisibility } from './PcbScene3dComponentVisibility.mjs'
6
5
  import { PcbScene3dComponentAdjustment } from './PcbScene3dComponentAdjustment.mjs'
7
6
  import { PcbScene3dComponentAdjustmentRegistry } from './PcbScene3dComponentAdjustmentRegistry.mjs'
7
+ import { PcbScene3dCompanionBasePlacementAdjuster } from './PcbScene3dCompanionBasePlacementAdjuster.mjs'
8
8
  import { PcbScene3dCopperDetailGroupBuilder } from './PcbScene3dCopperDetailGroupBuilder.mjs'
9
9
  import { PcbScene3dCopperFactory } from './PcbScene3dCopperFactory.mjs'
10
10
  import { PcbScene3dDetailCoordinateNormalizer } from './PcbScene3dDetailCoordinateNormalizer.mjs'
11
11
  import { PcbScene3dDrillVoidFactory } from './PcbScene3dDrillVoidFactory.mjs'
12
+ import { PcbScene3dExternalCompanionFallback } from './PcbScene3dExternalCompanionFallback.mjs'
12
13
  import { PcbScene3dExternalModels } from './PcbScene3dExternalModels.mjs'
14
+ import { PcbScene3dFallbackBodyFactory } from './PcbScene3dFallbackBodyFactory.mjs'
13
15
  import { PcbScene3dFallbackVisibility } from './PcbScene3dFallbackVisibility.mjs'
14
16
  import { PcbScene3dInteractionHints } from './PcbScene3dInteractionHints.mjs'
15
- import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
16
17
  import { PcbScene3dModelSearchPlacement } from './PcbScene3dModelSearchPlacement.mjs'
17
18
  import { PcbScene3dPresetState } from './PcbScene3dPresetState.mjs'
18
19
  import { PcbScene3dRenderGroupVisibility } from './PcbScene3dRenderGroupVisibility.mjs'
19
20
  import { PcbScene3dRenderScheduler } from './PcbScene3dRenderScheduler.mjs'
20
21
  import { PcbScene3dRuntimeBoardMeshes } from './PcbScene3dRuntimeBoardMeshes.mjs'
22
+ import { PcbScene3dRuntimeHelpers } from './PcbScene3dRuntimeHelpers.mjs'
21
23
  import { PcbScene3dSilkscreenChunkedFactory } from './PcbScene3dSilkscreenChunkedFactory.mjs'
22
24
  import { PcbScene3dTrueTypeTextFactory } from './PcbScene3dTrueTypeTextFactory.mjs'
23
25
  import { PcbScene3dSelectionResolver } from './PcbScene3dSelectionResolver.mjs'
26
+ import { PcbScene3dSelectionMarkerOverlay } from './PcbScene3dSelectionMarkerOverlay.mjs'
24
27
  import { PcbScene3dSelectionStyler } from './PcbScene3dSelectionStyler.mjs'
28
+ import { PcbScene3dStaticBodyFactory } from './PcbScene3dStaticBodyFactory.mjs'
25
29
  import { PcbScene3dViewportResize } from './PcbScene3dViewportResize.mjs'
26
30
  import { PcbScene3dViewScale } from './PcbScene3dViewScale.mjs'
27
31
  const SILKSCREEN_COPPER_CLEARANCE_MIL = 0.12
@@ -37,6 +41,7 @@ const Z_MIL = {
37
41
  export class PcbScene3dRuntime {
38
42
  #viewportNode
39
43
  #sceneDescription
44
+ #placementSceneDescription
40
45
  #hooks
41
46
  #toggles
42
47
  #groups
@@ -59,6 +64,7 @@ export class PcbScene3dRuntime {
59
64
  #fallbackBodyRoots
60
65
  #loadedExternalModelDesignators
61
66
  #modelSearchExternalModelRoots
67
+ #selectionMarkerOverlay
62
68
  #hasLoadedBoardAssemblyModel
63
69
  #selectedDesignator
64
70
  #initialRadius
@@ -78,6 +84,8 @@ export class PcbScene3dRuntime {
78
84
  PcbScene3dRuntime.#normalizeSceneDescription(sceneDescription)
79
85
  this.#viewportNode = viewportNode
80
86
  this.#sceneDescription = renderModel
87
+ this.#placementSceneDescription =
88
+ PcbScene3dCompanionBasePlacementAdjuster.adjust(renderModel)
81
89
  this.#hooks = hooks
82
90
  this.#toggles = {
83
91
  'external-models': true,
@@ -106,6 +114,7 @@ export class PcbScene3dRuntime {
106
114
  this.#fallbackBodyRoots = new Map()
107
115
  this.#loadedExternalModelDesignators = new Set()
108
116
  this.#modelSearchExternalModelRoots = new Set()
117
+ this.#selectionMarkerOverlay = null
109
118
  this.#hasLoadedBoardAssemblyModel = false
110
119
  this.#selectedDesignator = ''
111
120
  this.#initialRadius =
@@ -175,6 +184,7 @@ export class PcbScene3dRuntime {
175
184
  return
176
185
  }
177
186
  this.#applyToggleVisibility()
187
+ this.#updateSelectionMarker()
178
188
  this.#render()
179
189
  }
180
190
  /** @param {string} designator @returns {boolean} */
@@ -236,6 +246,7 @@ export class PcbScene3dRuntime {
236
246
  this.#fallbackBodyRoots.clear()
237
247
  this.#loadedExternalModelDesignators.clear()
238
248
  this.#modelSearchExternalModelRoots.clear()
249
+ this.#selectionMarkerOverlay = null
239
250
  this.#hasLoadedBoardAssemblyModel = false
240
251
  this.#selectedDesignator = ''
241
252
  this.#groups.clear()
@@ -385,20 +396,69 @@ export class PcbScene3dRuntime {
385
396
  this.#groups.set('copper', copperGroup)
386
397
  this.#rootGroup.add(copperGroup)
387
398
  const fallbackBodiesGroup = new THREE.Group()
399
+ const staticBodiesGroup = new THREE.Group()
388
400
  const externalModelsGroup = new THREE.Group()
401
+ const selectionMarkerGroup = new THREE.Group()
389
402
  this.#sceneDescription.components.forEach((component) => {
390
- const fallbackBody = this.#buildFallbackBody(component)
391
- fallbackBodiesGroup.add(fallbackBody)
403
+ if (component?.renderFallbackBody === false) {
404
+ return
405
+ }
406
+
407
+ const isCompanionBase =
408
+ PcbScene3dExternalCompanionFallback.shouldKeepFallback(
409
+ this.#sceneDescription,
410
+ component
411
+ )
412
+ const fallbackBody = PcbScene3dFallbackBodyFactory.build(
413
+ THREE,
414
+ component,
415
+ { companionBase: isCompanionBase }
416
+ )
417
+ if (isCompanionBase) {
418
+ fallbackBody.rootGroup.userData.scene3dFallbackExternalCompanion = true
419
+ }
420
+ fallbackBodiesGroup.add(fallbackBody.rootGroup)
392
421
  PcbScene3dFallbackVisibility.registerFallbackRoot(
393
422
  this.#fallbackBodyRoots,
394
423
  component?.designator,
395
- fallbackBody
424
+ fallbackBody.rootGroup
425
+ )
426
+ this.#registerSelectionRoot(
427
+ component?.designator,
428
+ fallbackBody.rootGroup
429
+ )
430
+ this.#componentAdjustmentRegistry.register(
431
+ component?.designator,
432
+ fallbackBody.adjustmentGroup
433
+ )
434
+ })
435
+ PcbScene3dStaticBodyFactory.buildMany(
436
+ THREE,
437
+ this.#placementSceneDescription.staticBodyPlacements
438
+ ).forEach((staticBody) => {
439
+ const selectionKey =
440
+ PcbScene3dStaticBodyFactory.resolveSelectionKey(
441
+ staticBody.placement
442
+ )
443
+ staticBodiesGroup.add(staticBody.rootGroup)
444
+ this.#registerSelectionRoot(selectionKey, staticBody.rootGroup)
445
+ this.#componentAdjustmentRegistry.register(
446
+ selectionKey,
447
+ staticBody.adjustmentGroup
396
448
  )
397
449
  })
398
450
  this.#groups.set('fallback-bodies', fallbackBodiesGroup)
451
+ this.#groups.set('static-bodies', staticBodiesGroup)
399
452
  this.#groups.set('external-models', externalModelsGroup)
453
+ this.#groups.set('selection-marker', selectionMarkerGroup)
454
+ this.#selectionMarkerOverlay = new PcbScene3dSelectionMarkerOverlay(
455
+ selectionMarkerGroup
456
+ )
400
457
  this.#rootGroup.add(fallbackBodiesGroup)
458
+ this.#rootGroup.add(staticBodiesGroup)
401
459
  this.#rootGroup.add(externalModelsGroup)
460
+ this.#rootGroup.add(selectionMarkerGroup)
461
+ this.#updateSelectionMarker()
402
462
  const boardSpan = Math.max(board.widthMil, board.heightMil, 1)
403
463
  this.#scene.fog = new THREE.Fog(
404
464
  0xf4f0ea,
@@ -442,7 +502,7 @@ export class PcbScene3dRuntime {
442
502
  */
443
503
  async #loadDeferredDetail() {
444
504
  try {
445
- await PcbScene3dRuntime.#yieldToNextFrame()
505
+ await PcbScene3dRuntimeHelpers.yieldToNextFrame(globalThis)
446
506
  if (this.#isDisposed) {
447
507
  return
448
508
  }
@@ -450,7 +510,7 @@ export class PcbScene3dRuntime {
450
510
  await this.#loadDeferredSilkscreen()
451
511
  this.#render()
452
512
 
453
- await PcbScene3dRuntime.#yieldToNextFrame()
513
+ await PcbScene3dRuntimeHelpers.yieldToNextFrame(globalThis)
454
514
  if (this.#isDisposed) {
455
515
  return
456
516
  }
@@ -459,7 +519,7 @@ export class PcbScene3dRuntime {
459
519
  this.#applyToggleVisibility()
460
520
  this.#render()
461
521
  this.#settleReady()
462
- await PcbScene3dRuntime.#yieldToNextFrame()
522
+ await PcbScene3dRuntimeHelpers.yieldToNextFrame(globalThis)
463
523
  if (this.#isDisposed) {
464
524
  return
465
525
  }
@@ -503,7 +563,8 @@ export class PcbScene3dRuntime {
503
563
  (x, y) => this.#normalizeDetailPoint(x, y),
504
564
  {
505
565
  shouldContinue: () => !this.#isDisposed,
506
- yieldToMain: () => PcbScene3dRuntime.#yieldToNextFrame()
566
+ yieldToMain: () =>
567
+ PcbScene3dRuntimeHelpers.yieldToNextFrame(globalThis)
507
568
  }
508
569
  )
509
570
  if (this.#isDisposed) return
@@ -536,63 +597,6 @@ export class PcbScene3dRuntime {
536
597
  }
537
598
  }
538
599
 
539
- /**
540
- * Builds one procedural fallback body mesh.
541
- * @param {{ positionMil: { x: number, y: number, z: number }, rotationDeg: number, mountSide: string, body: { family: string, sizeMil: { width: number, depth: number, height: number } } }} component
542
- * @returns {any}
543
- */
544
- #buildFallbackBody(component) {
545
- const THREE = this.#three
546
- const family = component.body.family
547
- const size = component.body.sizeMil
548
- const material = new THREE.MeshStandardMaterial({
549
- color: PcbScene3dBodyColor.resolve(family),
550
- roughness: 0.72,
551
- metalness: family === 'chip' ? 0.12 : 0.08
552
- })
553
-
554
- let mesh
555
- if (family === 'radial-capacitor' || family === 'test-point') {
556
- mesh = new THREE.Mesh(
557
- new THREE.CylinderGeometry(
558
- size.width / 2,
559
- size.width / 2,
560
- size.height,
561
- 28
562
- ),
563
- material
564
- )
565
- } else {
566
- mesh = new THREE.Mesh(
567
- new THREE.BoxGeometry(size.width, size.depth, size.height),
568
- material
569
- )
570
- }
571
-
572
- const mountRig = PcbScene3dMountRig.create(THREE, component)
573
- const rootGroup = mountRig.rootGroup
574
- const adjustmentGroup = new THREE.Group()
575
-
576
- rootGroup.userData.scene3dSelection = {
577
- designator: String(component?.designator || 'component'),
578
- sourceType: 'component'
579
- }
580
- adjustmentGroup.userData.scene3dAdjustmentTarget = true
581
- adjustmentGroup.userData.scene3dAdjustmentDesignator = String(
582
- component?.designator || 'component'
583
- )
584
- adjustmentGroup.userData.scene3dAdjustmentBaseline =
585
- PcbScene3dComponentAdjustment.neutral()
586
- this.#registerSelectionRoot(component?.designator, rootGroup)
587
- this.#componentAdjustmentRegistry.register(
588
- component?.designator,
589
- adjustmentGroup
590
- )
591
- adjustmentGroup.add(mesh)
592
- mountRig.faceGroup.add(adjustmentGroup)
593
- return rootGroup
594
- }
595
-
596
600
  /**
597
601
  * Attempts to load any resolved external 3D models.
598
602
  * @returns {Promise<void>}
@@ -605,7 +609,7 @@ export class PcbScene3dRuntime {
605
609
 
606
610
  const diagnostics = await PcbScene3dExternalModels.loadIntoScene({
607
611
  three: this.#three,
608
- sceneDescription: this.#sceneDescription,
612
+ sceneDescription: this.#placementSceneDescription,
609
613
  externalModelsGroup,
610
614
  modelViewScale: PcbScene3dRuntime.resolveViewScale(
611
615
  this.#presetState.get(),
@@ -726,7 +730,7 @@ export class PcbScene3dRuntime {
726
730
 
727
731
  if (
728
732
  !this.#pointerDownPosition ||
729
- PcbScene3dRuntime.#resolvePointerTravel(
733
+ PcbScene3dRuntimeHelpers.pointerTravel(
730
734
  this.#pointerDownPosition,
731
735
  {
732
736
  x: Number(event?.clientX || 0),
@@ -770,6 +774,7 @@ export class PcbScene3dRuntime {
770
774
  })
771
775
  PcbScene3dComponentVisibility.apply({
772
776
  selectionRoots: this.#selectionRoots,
777
+ selectedDesignator: this.#selectedDesignator,
773
778
  hiddenDesignators: this.#hiddenComponentDesignators,
774
779
  fallbackBodyRoots: this.#fallbackBodyRoots,
775
780
  loadedExternalModelDesignators:
@@ -780,6 +785,24 @@ export class PcbScene3dRuntime {
780
785
  })
781
786
  }
782
787
 
788
+ /**
789
+ * Rebuilds the flat selected-component marker overlay.
790
+ * @returns {void}
791
+ */
792
+ #updateSelectionMarker() {
793
+ this.#selectionMarkerOverlay?.update(
794
+ this.#three,
795
+ this.#sceneDescription,
796
+ this.#selectedDesignator,
797
+ PcbScene3dComponentVisibility.isHidden(
798
+ this.#hiddenComponentDesignators,
799
+ this.#selectedDesignator
800
+ ),
801
+ (x, y) => this.#normalizeDetailPoint(x, y),
802
+ { color: PcbScene3dRuntime.#resolveSelectionHighlightColor() }
803
+ )
804
+ }
805
+
783
806
  /** @returns {void} */
784
807
  #render() {
785
808
  if (!this.#renderer || !this.#scene || !this.#camera) {
@@ -802,26 +825,6 @@ export class PcbScene3dRuntime {
802
825
  this.#resolveReadyPromise = null
803
826
  }
804
827
 
805
- /**
806
- * Yields one turn so the browser can present the initial rendered frame
807
- * before deferred detail work continues.
808
- * @returns {Promise<void>}
809
- */
810
- static async #yieldToNextFrame() {
811
- if (
812
- typeof window !== 'undefined' &&
813
- typeof window.requestAnimationFrame === 'function'
814
- ) {
815
- await new Promise((resolve) => {
816
- window.requestAnimationFrame(() => resolve())
817
- })
818
- return
819
- }
820
- await new Promise((resolve) => {
821
- globalThis.setTimeout(resolve, 0)
822
- })
823
- }
824
-
825
828
  /**
826
829
  * Normalizes one board coordinate into the scene's centered board plane.
827
830
  * @param {number} x
@@ -891,6 +894,7 @@ export class PcbScene3dRuntime {
891
894
 
892
895
  const selectableRoots = [
893
896
  this.#groups.get('external-models'),
897
+ this.#groups.get('static-bodies'),
894
898
  this.#groups.get('fallback-bodies')
895
899
  ].filter((group) => group && group.visible !== false)
896
900
  const intersections = this.#raycaster.intersectObjects(
@@ -935,6 +939,8 @@ export class PcbScene3dRuntime {
935
939
  PcbScene3dRuntime.#resolveSelectionHighlightColor()
936
940
  )
937
941
  this.#selectedDesignator = normalizedDesignator
942
+ this.#applyToggleVisibility()
943
+ this.#updateSelectionMarker()
938
944
  this.#render()
939
945
  }
940
946
 
@@ -982,16 +988,4 @@ export class PcbScene3dRuntime {
982
988
  static #resolveSelectionHighlightColor() {
983
989
  return 0x14c5e6
984
990
  }
985
-
986
- /**
987
- * Resolves the drag travel between two pointer positions.
988
- * @param {{ x: number, y: number }} start
989
- * @param {{ x: number, y: number }} end
990
- * @returns {number}
991
- */
992
- static #resolvePointerTravel(start, end) {
993
- const dx = Number(end?.x || 0) - Number(start?.x || 0)
994
- const dy = Number(end?.y || 0) - Number(start?.y || 0)
995
- return Math.hypot(dx, dy)
996
- }
997
991
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Small runtime utilities that do not need access to private runtime state.
3
+ */
4
+ export class PcbScene3dRuntimeHelpers {
5
+ /**
6
+ * Yields one turn so the browser can present a rendered frame.
7
+ * @param {typeof globalThis} globalScope Runtime global object.
8
+ * @returns {Promise<void>}
9
+ */
10
+ static async yieldToNextFrame(globalScope) {
11
+ const frameScheduler =
12
+ globalScope?.window?.requestAnimationFrame ||
13
+ globalScope?.requestAnimationFrame
14
+ if (typeof frameScheduler === 'function') {
15
+ await new Promise((resolve) => {
16
+ frameScheduler.call(globalScope?.window || globalScope, () =>
17
+ resolve()
18
+ )
19
+ })
20
+ return
21
+ }
22
+
23
+ await new Promise((resolve) => {
24
+ globalScope?.setTimeout(resolve, 0)
25
+ })
26
+ }
27
+
28
+ /**
29
+ * Resolves the drag travel between two pointer positions.
30
+ * @param {{ x: number, y: number }} start Pointer-down position.
31
+ * @param {{ x: number, y: number }} end Pointer-up position.
32
+ * @returns {number}
33
+ */
34
+ static pointerTravel(start, end) {
35
+ const dx = Number(end?.x || 0) - Number(start?.x || 0)
36
+ const dy = Number(end?.y || 0) - Number(start?.y || 0)
37
+ return Math.hypot(dx, dy)
38
+ }
39
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Builds lookup indexes for 3D scene selections.
3
+ */
4
+ export class PcbScene3dSelectionIndexBuilder {
5
+ /**
6
+ * Builds one selected-key inspector lookup from the scene description.
7
+ * @param {{ components?: any[], externalPlacements?: any[], staticBodyPlacements?: any[] }} sceneDescription Scene data.
8
+ * @returns {Map<string, { component: any | null, externalPlacement: any | null, staticBodyPlacement: any | null }>}
9
+ */
10
+ static build(sceneDescription) {
11
+ const index = new Map()
12
+ const components = Array.isArray(sceneDescription?.components)
13
+ ? sceneDescription.components
14
+ : []
15
+ const externalPlacements = Array.isArray(
16
+ sceneDescription?.externalPlacements
17
+ )
18
+ ? sceneDescription.externalPlacements
19
+ : []
20
+ const staticBodyPlacements = Array.isArray(
21
+ sceneDescription?.staticBodyPlacements
22
+ )
23
+ ? sceneDescription.staticBodyPlacements
24
+ : []
25
+
26
+ components.forEach((component) => {
27
+ const designator = String(component?.designator || '').trim()
28
+ if (!designator) {
29
+ return
30
+ }
31
+
32
+ index.set(designator, {
33
+ component,
34
+ externalPlacement:
35
+ index.get(designator)?.externalPlacement || null,
36
+ staticBodyPlacement:
37
+ index.get(designator)?.staticBodyPlacement || null
38
+ })
39
+ })
40
+
41
+ externalPlacements.forEach((externalPlacement) => {
42
+ const designator = String(
43
+ externalPlacement?.designator || ''
44
+ ).trim()
45
+ if (!designator) {
46
+ return
47
+ }
48
+
49
+ index.set(designator, {
50
+ component: index.get(designator)?.component || null,
51
+ externalPlacement,
52
+ staticBodyPlacement:
53
+ index.get(designator)?.staticBodyPlacement || null
54
+ })
55
+ })
56
+
57
+ staticBodyPlacements.forEach((staticBodyPlacement) => {
58
+ const selectionKey =
59
+ PcbScene3dSelectionIndexBuilder.#resolveStaticBodySelectionKey(
60
+ staticBodyPlacement
61
+ )
62
+ if (!selectionKey) {
63
+ return
64
+ }
65
+
66
+ index.set(selectionKey, {
67
+ component: index.get(selectionKey)?.component || null,
68
+ externalPlacement:
69
+ index.get(selectionKey)?.externalPlacement || null,
70
+ staticBodyPlacement
71
+ })
72
+ })
73
+
74
+ return index
75
+ }
76
+
77
+ /**
78
+ * Resolves the selectable key for one static body placement.
79
+ * @param {{ designator?: string, selectionKey?: string }} placement Static body placement.
80
+ * @returns {string}
81
+ */
82
+ static #resolveStaticBodySelectionKey(placement) {
83
+ return String(
84
+ placement?.selectionKey || placement?.designator || ''
85
+ ).trim()
86
+ }
87
+ }
@@ -46,37 +46,49 @@ export class PcbScene3dSelectionInspectorRenderer {
46
46
 
47
47
  /**
48
48
  * Renders selected component details and optional editable transform controls.
49
- * @param {{ designator: string, hidden?: boolean, selection?: { sourceType?: string } | null, selectionEntry: { component: any | null, externalPlacement: any | null }, adjustment: { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }, includeControls?: boolean, translate: (key: string) => string }} options Render options.
49
+ * @param {{ designator: string, hidden?: boolean, selection?: { sourceType?: string } | null, selectionEntry: { component: any | null, externalPlacement: any | null, staticBodyPlacement?: any | null }, adjustment: { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }, includeControls?: boolean, translate: (key: string) => string }} options Render options.
50
50
  * @returns {string}
51
51
  */
52
52
  static renderSelected(options) {
53
53
  const component = options.selectionEntry.component
54
54
  const externalPlacement = options.selectionEntry.externalPlacement
55
+ const staticBodyPlacement =
56
+ options.selectionEntry.staticBodyPlacement || null
57
+ const displayDesignator =
58
+ staticBodyPlacement?.designator || options.designator
55
59
  const hidden = options.hidden === true
56
60
  const fields = [
57
- [options.translate('scene3d.designator'), options.designator],
61
+ [options.translate('scene3d.designator'), displayDesignator],
58
62
  [
59
63
  options.translate('scene3d.picked'),
60
- options.selection?.sourceType === 'external-model'
61
- ? options.translate('scene3d.externalModel')
62
- : options.translate('scene3d.fallbackBody')
64
+ PcbScene3dSelectionInspectorRenderer.#formatSourceType(
65
+ options.selection,
66
+ options.selectionEntry,
67
+ options.translate
68
+ )
63
69
  ],
64
70
  [
65
71
  options.translate('scene3d.mountSide'),
66
- externalPlacement?.mountSide || component?.mountSide || ''
72
+ externalPlacement?.mountSide ||
73
+ component?.mountSide ||
74
+ staticBodyPlacement?.mountSide ||
75
+ ''
67
76
  ],
68
77
  [
69
78
  options.translate('scene3d.rotation'),
70
79
  PcbScene3dSelectionInspectorRenderer.#formatValue(
71
- component?.rotationDeg ?? externalPlacement?.rotationDeg,
80
+ component?.rotationDeg ??
81
+ externalPlacement?.rotationDeg ??
82
+ staticBodyPlacement?.rotationDeg,
72
83
  'deg'
73
84
  )
74
85
  ],
75
86
  [
76
87
  options.translate('scene3d.boardPosition'),
77
- component?.boardPositionMil
88
+ component?.boardPositionMil || staticBodyPlacement?.positionMil
78
89
  ? PcbScene3dSelectionInspectorRenderer.#formatPoint(
79
- component.boardPositionMil,
90
+ component?.boardPositionMil ||
91
+ staticBodyPlacement?.positionMil,
80
92
  true
81
93
  )
82
94
  : ''
@@ -98,9 +110,11 @@ export class PcbScene3dSelectionInspectorRenderer {
98
110
  ],
99
111
  [
100
112
  options.translate('scene3d.bodyPosition'),
101
- externalPlacement?.bodyPositionMil
113
+ externalPlacement?.bodyPositionMil ||
114
+ staticBodyPlacement?.bodyPositionMil
102
115
  ? PcbScene3dSelectionInspectorRenderer.#formatPoint(
103
- externalPlacement.bodyPositionMil,
116
+ externalPlacement?.bodyPositionMil ||
117
+ staticBodyPlacement?.bodyPositionMil,
104
118
  false
105
119
  )
106
120
  : ''
@@ -536,6 +550,31 @@ export class PcbScene3dSelectionInspectorRenderer {
536
550
  : ''
537
551
  }
538
552
 
553
+ /**
554
+ * Formats one selection source type label.
555
+ * @param {{ sourceType?: string } | null | undefined} selection Selection payload.
556
+ * @param {{ externalPlacement?: any | null, staticBodyPlacement?: any | null }} selectionEntry Selection metadata.
557
+ * @param {(key: string) => string} translate Translation lookup.
558
+ * @returns {string}
559
+ */
560
+ static #formatSourceType(selection, selectionEntry, translate) {
561
+ if (
562
+ selection?.sourceType === 'external-model' ||
563
+ selectionEntry?.externalPlacement
564
+ ) {
565
+ return translate('scene3d.externalModel')
566
+ }
567
+
568
+ if (
569
+ selection?.sourceType === 'static-body' ||
570
+ selectionEntry?.staticBodyPlacement
571
+ ) {
572
+ return translate('scene3d.staticBody')
573
+ }
574
+
575
+ return translate('scene3d.fallbackBody')
576
+ }
577
+
539
578
  /**
540
579
  * Formats one rotation triplet.
541
580
  * @param {{ x?: number, y?: number, z?: number } | null | undefined} rotationDeg Rotation.