pcb-scene3d-viewer 1.1.20 → 1.1.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pcb-scene3d-viewer",
3
- "version": "1.1.20",
3
+ "version": "1.1.21",
4
4
  "description": "Reusable Three.js PCB 3D scene viewer for normalized ECAD and CircuitJSON scene descriptions",
5
5
  "keywords": [
6
6
  "pcb",
@@ -267,12 +267,18 @@ export class PcbScene3dCameraRig {
267
267
  */
268
268
  static #applyOrthographicProjection(camera, state) {
269
269
  const zoom = Math.max(Number(camera.zoom || 1), 0.0001)
270
- const height = Math.max(Number(state.orthographicHeight || 0) / zoom, 1)
270
+ const height = Math.max(Number(state.orthographicHeight || 0), 1)
271
271
  const width = height * Math.max(Number(camera.aspect || 1), 0.0001)
272
272
  const left = -width / 2
273
273
  const right = width / 2
274
274
  const top = height / 2
275
275
  const bottom = -height / 2
276
+ // Keep camera bounds unzoomed for OrbitControls pan math; apply zoom
277
+ // only to the projection matrix so dragging stays cursor-locked.
278
+ const projectedLeft = left / zoom
279
+ const projectedRight = right / zoom
280
+ const projectedTop = top / zoom
281
+ const projectedBottom = bottom / zoom
276
282
 
277
283
  camera.left = left
278
284
  camera.right = right
@@ -281,10 +287,10 @@ export class PcbScene3dCameraRig {
281
287
  camera.isPerspectiveCamera = false
282
288
  camera.isOrthographicCamera = true
283
289
  camera.projectionMatrix?.makeOrthographic?.(
284
- left,
285
- right,
286
- top,
287
- bottom,
290
+ projectedLeft,
291
+ projectedRight,
292
+ projectedTop,
293
+ projectedBottom,
288
294
  camera.near,
289
295
  camera.far
290
296
  )
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Applies per-component hidden state on top of existing 3D visibility toggles.
3
+ */
4
+ export class PcbScene3dComponentVisibility {
5
+ /**
6
+ * Stores one component hidden state.
7
+ * @param {Set<string>} hiddenDesignators Hidden designator set.
8
+ * @param {string} designator Component designator.
9
+ * @param {boolean} hidden Whether the component should be hidden.
10
+ * @returns {boolean}
11
+ */
12
+ static setHidden(hiddenDesignators, designator, hidden) {
13
+ if (!(hiddenDesignators instanceof Set)) {
14
+ return false
15
+ }
16
+
17
+ const normalizedDesignator =
18
+ PcbScene3dComponentVisibility.#normalizeDesignator(designator)
19
+ if (!normalizedDesignator) {
20
+ return false
21
+ }
22
+
23
+ const wasHidden = hiddenDesignators.has(normalizedDesignator)
24
+ if (hidden) {
25
+ hiddenDesignators.add(normalizedDesignator)
26
+ } else {
27
+ hiddenDesignators.delete(normalizedDesignator)
28
+ }
29
+ return wasHidden !== hiddenDesignators.has(normalizedDesignator)
30
+ }
31
+
32
+ /**
33
+ * Checks whether one component is hidden.
34
+ * @param {Set<string>} hiddenDesignators Hidden designator set.
35
+ * @param {string} designator Component designator.
36
+ * @returns {boolean}
37
+ */
38
+ static isHidden(hiddenDesignators, designator) {
39
+ return (
40
+ hiddenDesignators instanceof Set &&
41
+ hiddenDesignators.has(
42
+ PcbScene3dComponentVisibility.#normalizeDesignator(designator)
43
+ )
44
+ )
45
+ }
46
+
47
+ /**
48
+ * Applies component visibility to all selectable render roots.
49
+ * @param {{ selectionRoots?: Map<string, Set<any>>, hiddenDesignators?: Set<string>, fallbackBodyRoots?: Map<string, Set<any>>, loadedExternalModelDesignators?: Set<string>, modelSearchExternalModelRoots?: Set<any>, toggles?: { 'external-models'?: boolean, 'fallback-bodies'?: boolean, 'model-search-models'?: boolean }, hasLoadedBoardAssemblyModel?: boolean }} state Visibility state.
50
+ * @returns {void}
51
+ */
52
+ static apply(state) {
53
+ const selectionRoots = state?.selectionRoots
54
+ if (!(selectionRoots instanceof Map)) {
55
+ return
56
+ }
57
+
58
+ selectionRoots.forEach((roots, designator) => {
59
+ roots?.forEach?.((rootObject) => {
60
+ if (!rootObject) {
61
+ return
62
+ }
63
+
64
+ rootObject.visible =
65
+ !PcbScene3dComponentVisibility.isHidden(
66
+ state?.hiddenDesignators,
67
+ designator
68
+ ) &&
69
+ PcbScene3dComponentVisibility.#resolveRootVisible(
70
+ state,
71
+ designator,
72
+ rootObject
73
+ )
74
+ })
75
+ })
76
+ }
77
+
78
+ /**
79
+ * Resolves root-level visibility before selected-component hiding.
80
+ * @param {{ fallbackBodyRoots?: Map<string, Set<any>>, loadedExternalModelDesignators?: Set<string>, modelSearchExternalModelRoots?: Set<any>, toggles?: { 'external-models'?: boolean, 'fallback-bodies'?: boolean, 'model-search-models'?: boolean }, hasLoadedBoardAssemblyModel?: boolean }} state Visibility state.
81
+ * @param {string} designator Component designator.
82
+ * @param {any} rootObject Render root.
83
+ * @returns {boolean}
84
+ */
85
+ static #resolveRootVisible(state, designator, rootObject) {
86
+ if (
87
+ PcbScene3dComponentVisibility.#isFallbackRoot(
88
+ state?.fallbackBodyRoots,
89
+ designator,
90
+ rootObject
91
+ )
92
+ ) {
93
+ return PcbScene3dComponentVisibility.#resolveFallbackVisible(
94
+ state,
95
+ designator
96
+ )
97
+ }
98
+
99
+ if (state?.modelSearchExternalModelRoots?.has?.(rootObject)) {
100
+ return (
101
+ Boolean(state?.toggles?.['external-models']) &&
102
+ state?.toggles?.['model-search-models'] !== false
103
+ )
104
+ }
105
+
106
+ return true
107
+ }
108
+
109
+ /**
110
+ * Resolves fallback root visibility from current detail toggles.
111
+ * @param {{ loadedExternalModelDesignators?: Set<string>, toggles?: { 'external-models'?: boolean, 'fallback-bodies'?: boolean }, hasLoadedBoardAssemblyModel?: boolean }} state Visibility state.
112
+ * @param {string} designator Component designator.
113
+ * @returns {boolean}
114
+ */
115
+ static #resolveFallbackVisible(state, designator) {
116
+ const boardAssemblyActive =
117
+ Boolean(state?.hasLoadedBoardAssemblyModel) &&
118
+ Boolean(state?.toggles?.['external-models'])
119
+ const showFallbackBodies =
120
+ Boolean(state?.toggles?.['fallback-bodies']) && !boardAssemblyActive
121
+ const hideForLoadedExternal =
122
+ Boolean(state?.toggles?.['external-models']) &&
123
+ state?.loadedExternalModelDesignators?.has?.(
124
+ PcbScene3dComponentVisibility.#normalizeDesignator(designator)
125
+ )
126
+
127
+ return showFallbackBodies && !hideForLoadedExternal
128
+ }
129
+
130
+ /**
131
+ * Checks whether a root belongs to fallback-body tracking.
132
+ * @param {Map<string, Set<any>> | undefined} fallbackBodyRoots Fallback roots.
133
+ * @param {string} designator Component designator.
134
+ * @param {any} rootObject Render root.
135
+ * @returns {boolean}
136
+ */
137
+ static #isFallbackRoot(fallbackBodyRoots, designator, rootObject) {
138
+ if (!(fallbackBodyRoots instanceof Map)) {
139
+ return false
140
+ }
141
+
142
+ return Boolean(
143
+ fallbackBodyRoots
144
+ .get(
145
+ PcbScene3dComponentVisibility.#normalizeDesignator(
146
+ designator
147
+ )
148
+ )
149
+ ?.has?.(rootObject)
150
+ )
151
+ }
152
+
153
+ /**
154
+ * Normalizes component designator keys.
155
+ * @param {string} designator Raw designator.
156
+ * @returns {string}
157
+ */
158
+ static #normalizeDesignator(designator) {
159
+ return String(designator || '').trim()
160
+ }
161
+ }
@@ -4,6 +4,7 @@ import { PcbScene3dCircuitJsonAdapter } from './PcbScene3dCircuitJsonAdapter.mjs
4
4
  import { PcbScene3dInteractionHints } from './PcbScene3dInteractionHints.mjs'
5
5
  import { PcbScene3dRuntime } from './PcbScene3dRuntime.mjs'
6
6
  import { PcbScene3dSelectionInspectorRenderer } from './PcbScene3dSelectionInspectorRenderer.mjs'
7
+ import { PcbScene3dSelectionVisibilityBinder } from './PcbScene3dSelectionVisibilityBinder.mjs'
7
8
  import { PcbScene3dText } from './PcbScene3dText.mjs'
8
9
 
9
10
  /**
@@ -31,6 +32,8 @@ export class PcbScene3dController {
31
32
  /** @type {PcbScene3dAdjustmentControlBinder | null} */
32
33
  #adjustmentControls
33
34
 
35
+ /** @type {PcbScene3dSelectionVisibilityBinder | null} */
36
+ #visibilityControls
34
37
  /** @type {Array<{ node: EventTarget, type: string, listener: (event: any) => void }>} */
35
38
  #listeners
36
39
 
@@ -43,7 +46,7 @@ export class PcbScene3dController {
43
46
  /** @type {string} */
44
47
  #selectedComponentKey
45
48
 
46
- /** @type {{ setPreset?: (preset: string) => void, setToggle?: (toggleName: string, enabled: boolean) => void, setSelectedDesignator?: (designator: string) => void, setComponentAdjustment?: (designator: string, adjustment: { scale: { x: number, y: number, z: number }, rotationDeg: { x: number, y: number, z: number }, offsetMil: { x: number, y: number, z: number } }) => void, dispose?: () => void } | null} */
49
+ /** @type {any | null} */
47
50
  #runtime
48
51
 
49
52
  /** @type {any | null} */
@@ -151,19 +154,36 @@ export class PcbScene3dController {
151
154
  suppressed ? '' : this.#selectedComponentKey
152
155
  )
153
156
  },
154
- refreshSelection: (designator) => {
157
+ refreshSelection: (designator, sourceType) => {
155
158
  this.#setSelection({
156
159
  designator,
157
- sourceType: this.#resolveSelectionSourceType(designator)
160
+ sourceType:
161
+ sourceType ||
162
+ this.#resolveSelectionSourceType(designator)
163
+ })
164
+ }
165
+ })
166
+ this.#visibilityControls = new PcbScene3dSelectionVisibilityBinder({
167
+ resolveDesignator: () => this.#selectedComponentKey,
168
+ resolveHidden: (designator) =>
169
+ Boolean(this.#runtime?.isComponentHidden?.(designator)),
170
+ setHidden: (designator, hidden) => {
171
+ this.#runtime?.setComponentHidden?.(designator, hidden)
172
+ },
173
+ refreshSelection: (designator, source) => {
174
+ this.#setSelection({
175
+ designator,
176
+ sourceType:
177
+ source || this.#resolveSelectionSourceType(designator)
158
178
  })
159
179
  }
160
180
  })
161
-
162
181
  this.#bindPresets()
163
182
  this.#setActivePresetButton('isometric')
164
183
  this.#bindToggles()
165
184
  this.#bindExportAction()
166
185
  this.#adjustmentControls.bindSelectionNode(this.#selectionNode)
186
+ this.#visibilityControls.bindSelectionNode(this.#selectionNode)
167
187
  this.#setSelection(null)
168
188
  this.#setLoadingVisible(true)
169
189
  const circuitJsonModel = PcbScene3dController.#resolveCircuitJsonModel(
@@ -287,6 +307,8 @@ export class PcbScene3dController {
287
307
  this.#scenePrepClient = null
288
308
  this.#adjustmentControls?.dispose()
289
309
  this.#adjustmentControls = null
310
+ this.#visibilityControls?.dispose()
311
+ this.#visibilityControls = null
290
312
  this.#runtime?.dispose?.()
291
313
  this.#runtime = null
292
314
  this.#sceneDescription = null
@@ -739,6 +761,9 @@ export class PcbScene3dController {
739
761
  this.#selectionNode.innerHTML =
740
762
  PcbScene3dSelectionInspectorRenderer.renderSelected({
741
763
  designator,
764
+ hidden: Boolean(
765
+ this.#runtime?.isComponentHidden?.(designator)
766
+ ),
742
767
  selection,
743
768
  selectionEntry,
744
769
  adjustment,
@@ -13,7 +13,7 @@ export class PcbScene3dExternalModelPlacementRepair {
13
13
  '#333333',
14
14
  '#3f3f3f'
15
15
  ])
16
- static #MIN_CENTER_ERROR_MIL = 25
16
+ static #MIN_CENTER_ERROR_MIL = 1
17
17
  static #MIN_TERMINAL_SIZE_MIL = 1
18
18
  static #SOURCE_ORIGIN_SHIFT_EPSILON_MIL = 0.01
19
19
  static #SOURCE_Z_DEPTH_MIN_RATIO = 1.5
@@ -6,24 +6,32 @@ export class PcbScene3dInteractionHints {
6
6
  * Applies explicit OrbitControls input mappings for desktop and touch.
7
7
  * @param {{ mouseButtons?: any, touches?: any }} controls
8
8
  * @param {{ MOUSE?: any, TOUCH?: any }} THREE
9
+ * @param {string} [preset] Active camera preset.
9
10
  * @returns {void}
10
11
  */
11
- static configureControls(controls, THREE) {
12
+ static configureControls(controls, THREE, preset = 'isometric') {
12
13
  if (!controls) {
13
14
  return
14
15
  }
15
16
 
17
+ const inspectionPreset =
18
+ String(preset || '').toLowerCase() === 'top' ||
19
+ String(preset || '').toLowerCase() === 'bottom'
20
+
16
21
  if (THREE?.MOUSE) {
17
22
  controls.mouseButtons = {
18
- LEFT: THREE.MOUSE.ROTATE,
23
+ LEFT: inspectionPreset ? THREE.MOUSE.PAN : THREE.MOUSE.ROTATE,
19
24
  MIDDLE: THREE.MOUSE.DOLLY,
20
- RIGHT: THREE.MOUSE.PAN
25
+ RIGHT: inspectionPreset ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN
21
26
  }
22
27
  }
23
28
 
24
29
  if (THREE?.TOUCH) {
25
30
  controls.touches = {
26
- ONE: THREE.TOUCH.ROTATE,
31
+ ONE:
32
+ inspectionPreset && THREE.TOUCH.PAN
33
+ ? THREE.TOUCH.PAN
34
+ : THREE.TOUCH.ROTATE,
27
35
  TWO: THREE.TOUCH.DOLLY_PAN
28
36
  }
29
37
  }
@@ -44,13 +52,13 @@ export class PcbScene3dInteractionHints {
44
52
  const value = translate('scene3d.touchHint')
45
53
  if (value && value !== 'scene3d.touchHint') return value
46
54
  }
47
- return 'Drag with one finger to orbit, pinch to zoom, and drag with two fingers to pan.'
55
+ return 'One-finger drag pans in Top/Bottom and orbits in Isometric. Pinch to zoom.'
48
56
  }
49
57
 
50
58
  if (typeof translate === 'function') {
51
59
  const value = translate('scene3d.pointerHint')
52
60
  if (value && value !== 'scene3d.pointerHint') return value
53
61
  }
54
- return 'Drag to orbit, right-drag to pan, and use the wheel to zoom.'
62
+ return 'Drag pans in Top/Bottom and orbits in Isometric; right-drag uses the alternate action. Use the wheel to zoom.'
55
63
  }
56
64
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Coalesces high-frequency scene renders into animation frames.
3
+ */
4
+ export class PcbScene3dRenderScheduler {
5
+ /** @type {() => void} */
6
+ #render
7
+
8
+ /** @type {number | null} */
9
+ #pendingFrame
10
+
11
+ /**
12
+ * @param {() => void} render Render callback.
13
+ */
14
+ constructor(render) {
15
+ this.#render = render
16
+ this.#pendingFrame = null
17
+ }
18
+
19
+ /**
20
+ * Queues a render for the next animation frame.
21
+ * @returns {void}
22
+ */
23
+ schedule() {
24
+ if (this.#pendingFrame !== null) {
25
+ return
26
+ }
27
+
28
+ const requestFrame = globalThis.window?.requestAnimationFrame
29
+ if (typeof requestFrame !== 'function') {
30
+ this.#render()
31
+ return
32
+ }
33
+
34
+ this.#pendingFrame = requestFrame(() => {
35
+ this.#pendingFrame = null
36
+ this.#render()
37
+ })
38
+ }
39
+
40
+ /**
41
+ * Cancels one pending animation-frame render.
42
+ * @returns {void}
43
+ */
44
+ cancel() {
45
+ if (this.#pendingFrame === null) {
46
+ return
47
+ }
48
+
49
+ const cancelFrame = globalThis.window?.cancelAnimationFrame
50
+ if (typeof cancelFrame === 'function') {
51
+ cancelFrame(this.#pendingFrame)
52
+ }
53
+ this.#pendingFrame = null
54
+ }
55
+ }
@@ -2,6 +2,7 @@ import { PcbScene3dBoardSolderMaskFactory } from './PcbScene3dBoardSolderMaskFac
2
2
  import { PcbScene3dBodyColor } from './PcbScene3dBodyColor.mjs'
3
3
  import { PcbScene3dCameraRig } from './PcbScene3dCameraRig.mjs'
4
4
  import { PcbScene3dCircuitJsonAdapter } from './PcbScene3dCircuitJsonAdapter.mjs'
5
+ import { PcbScene3dComponentVisibility } from './PcbScene3dComponentVisibility.mjs'
5
6
  import { PcbScene3dComponentAdjustment } from './PcbScene3dComponentAdjustment.mjs'
6
7
  import { PcbScene3dComponentAdjustmentRegistry } from './PcbScene3dComponentAdjustmentRegistry.mjs'
7
8
  import { PcbScene3dCopperDetailGroupBuilder } from './PcbScene3dCopperDetailGroupBuilder.mjs'
@@ -15,6 +16,7 @@ import { PcbScene3dMountRig } from './PcbScene3dMountRig.mjs'
15
16
  import { PcbScene3dModelSearchPlacement } from './PcbScene3dModelSearchPlacement.mjs'
16
17
  import { PcbScene3dPresetState } from './PcbScene3dPresetState.mjs'
17
18
  import { PcbScene3dRenderGroupVisibility } from './PcbScene3dRenderGroupVisibility.mjs'
19
+ import { PcbScene3dRenderScheduler } from './PcbScene3dRenderScheduler.mjs'
18
20
  import { PcbScene3dRuntimeBoardMeshes } from './PcbScene3dRuntimeBoardMeshes.mjs'
19
21
  import { PcbScene3dSilkscreenChunkedFactory } from './PcbScene3dSilkscreenChunkedFactory.mjs'
20
22
  import { PcbScene3dTrueTypeTextFactory } from './PcbScene3dTrueTypeTextFactory.mjs'
@@ -52,6 +54,7 @@ export class PcbScene3dRuntime {
52
54
  #pointer
53
55
  #pointerDownPosition
54
56
  #selectionRoots
57
+ #hiddenComponentDesignators
55
58
  #componentAdjustmentRegistry
56
59
  #fallbackBodyRoots
57
60
  #loadedExternalModelDesignators
@@ -64,6 +67,7 @@ export class PcbScene3dRuntime {
64
67
  #readyPromise
65
68
  #resolveReadyPromise
66
69
  #hasSettledReady
70
+ #renderScheduler
67
71
  /**
68
72
  * @param {HTMLElement} viewportNode
69
73
  * @param {any} sceneDescription Scene description or CircuitJSON model.
@@ -96,6 +100,7 @@ export class PcbScene3dRuntime {
96
100
  this.#pointer = null
97
101
  this.#pointerDownPosition = null
98
102
  this.#selectionRoots = new Map()
103
+ this.#hiddenComponentDesignators = new Set()
99
104
  this.#componentAdjustmentRegistry =
100
105
  new PcbScene3dComponentAdjustmentRegistry(() => this.#three)
101
106
  this.#fallbackBodyRoots = new Map()
@@ -108,6 +113,11 @@ export class PcbScene3dRuntime {
108
113
  this.#presetState = new PcbScene3dPresetState()
109
114
  this.#isDisposed = false
110
115
  this.#hasSettledReady = false
116
+ this.#renderScheduler = new PcbScene3dRenderScheduler(() => {
117
+ if (!this.#isDisposed) {
118
+ this.#render()
119
+ }
120
+ })
111
121
  this.#resolveReadyPromise = null
112
122
  this.#readyPromise = new Promise((resolve) => {
113
123
  this.#resolveReadyPromise = resolve
@@ -124,10 +134,14 @@ export class PcbScene3dRuntime {
124
134
  setPreset(preset) {
125
135
  const normalizedPreset = this.#presetState.set(preset)
126
136
  this.#applyViewScale(normalizedPreset)
137
+ PcbScene3dInteractionHints.configureControls(
138
+ this.#controls,
139
+ this.#three,
140
+ normalizedPreset
141
+ )
127
142
  if (!this.#camera) {
128
143
  return
129
144
  }
130
-
131
145
  PcbScene3dCameraRig.applyPreset(
132
146
  this.#camera,
133
147
  this.#controls,
@@ -141,7 +155,6 @@ export class PcbScene3dRuntime {
141
155
  if (!(toggleName in this.#toggles)) {
142
156
  return
143
157
  }
144
-
145
158
  this.#toggles[toggleName] = enabled
146
159
  this.#applyToggleVisibility()
147
160
  this.#render()
@@ -150,6 +163,27 @@ export class PcbScene3dRuntime {
150
163
  setSelectedDesignator(designator) {
151
164
  this.#setSelectedDesignator(designator)
152
165
  }
166
+ /** @param {string} designator @param {boolean} hidden */
167
+ setComponentHidden(designator, hidden) {
168
+ if (
169
+ !PcbScene3dComponentVisibility.setHidden(
170
+ this.#hiddenComponentDesignators,
171
+ designator,
172
+ hidden
173
+ )
174
+ ) {
175
+ return
176
+ }
177
+ this.#applyToggleVisibility()
178
+ this.#render()
179
+ }
180
+ /** @param {string} designator @returns {boolean} */
181
+ isComponentHidden(designator) {
182
+ return PcbScene3dComponentVisibility.isHidden(
183
+ this.#hiddenComponentDesignators,
184
+ designator
185
+ )
186
+ }
153
187
  /**
154
188
  * Applies a live model-local adjustment to one component.
155
189
  * @param {string} designator Component designator.
@@ -172,6 +206,7 @@ export class PcbScene3dRuntime {
172
206
  /** @returns {void} */
173
207
  dispose() {
174
208
  this.#isDisposed = true
209
+ this.#renderScheduler.cancel()
175
210
  this.#listeners.forEach(({ node, type, listener }) => {
176
211
  node.removeEventListener?.(type, listener)
177
212
  })
@@ -196,6 +231,7 @@ export class PcbScene3dRuntime {
196
231
  this.#pointer = null
197
232
  this.#pointerDownPosition = null
198
233
  this.#selectionRoots.clear()
234
+ this.#hiddenComponentDesignators.clear()
199
235
  this.#componentAdjustmentRegistry.clear()
200
236
  this.#fallbackBodyRoots.clear()
201
237
  this.#loadedExternalModelDesignators.clear()
@@ -636,7 +672,11 @@ export class PcbScene3dRuntime {
636
672
  this.#controls.minDistance = 140
637
673
  this.#controls.maxDistance = this.#initialRadius * 8
638
674
  this.#controls.target.set(0, 0, 0)
639
- PcbScene3dInteractionHints.configureControls(this.#controls, THREE)
675
+ PcbScene3dInteractionHints.configureControls(
676
+ this.#controls,
677
+ THREE,
678
+ this.#presetState.get()
679
+ )
640
680
 
641
681
  PcbScene3dCameraRig.applyPreset(
642
682
  this.#camera,
@@ -645,7 +685,9 @@ export class PcbScene3dRuntime {
645
685
  this.#sceneDescription
646
686
  )
647
687
 
648
- this.#bindListener(this.#controls, 'change', () => this.#render())
688
+ this.#bindListener(this.#controls, 'change', () => {
689
+ this.#renderScheduler.schedule()
690
+ })
649
691
  this.#bindListener(domElement, 'contextmenu', (event) => {
650
692
  event.preventDefault?.()
651
693
  })
@@ -726,6 +768,16 @@ export class PcbScene3dRuntime {
726
768
  modelSearchExternalModelRoots: this.#modelSearchExternalModelRoots,
727
769
  hasLoadedBoardAssemblyModel: this.#hasLoadedBoardAssemblyModel
728
770
  })
771
+ PcbScene3dComponentVisibility.apply({
772
+ selectionRoots: this.#selectionRoots,
773
+ hiddenDesignators: this.#hiddenComponentDesignators,
774
+ fallbackBodyRoots: this.#fallbackBodyRoots,
775
+ loadedExternalModelDesignators:
776
+ this.#loadedExternalModelDesignators,
777
+ modelSearchExternalModelRoots: this.#modelSearchExternalModelRoots,
778
+ toggles: this.#toggles,
779
+ hasLoadedBoardAssemblyModel: this.#hasLoadedBoardAssemblyModel
780
+ })
729
781
  }
730
782
 
731
783
  /** @returns {void} */
@@ -46,12 +46,13 @@ export class PcbScene3dSelectionInspectorRenderer {
46
46
 
47
47
  /**
48
48
  * Renders selected component details and optional editable transform controls.
49
- * @param {{ designator: string, 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 }, 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 hidden = options.hidden === true
55
56
  const fields = [
56
57
  [options.translate('scene3d.designator'), options.designator],
57
58
  [
@@ -127,11 +128,18 @@ export class PcbScene3dSelectionInspectorRenderer {
127
128
  ].filter(([, value]) => String(value || '').trim())
128
129
 
129
130
  return (
130
- '<h4 class="scene-3d__selection-title">' +
131
+ '<div class="scene-3d__selection-header"><h4 class="scene-3d__selection-title">' +
131
132
  PcbScene3dSelectionInspectorRenderer.#escapeHtml(
132
133
  options.translate('scene3d.componentInspector')
133
134
  ) +
134
- '</h4><dl class="scene-3d__selection-list">' +
135
+ '</h4>' +
136
+ PcbScene3dSelectionInspectorRenderer.#renderVisibilityToggle(
137
+ options.designator,
138
+ options.selection?.sourceType || '',
139
+ hidden,
140
+ options.translate
141
+ ) +
142
+ '</div><dl class="scene-3d__selection-list">' +
135
143
  fields
136
144
  .map(
137
145
  ([label, value]) =>
@@ -461,6 +469,59 @@ export class PcbScene3dSelectionInspectorRenderer {
461
469
  )
462
470
  }
463
471
 
472
+ /**
473
+ * Renders the selected-component visibility toggle.
474
+ * @param {string} designator Selected component designator.
475
+ * @param {string} sourceType Selection source type.
476
+ * @param {boolean} hidden Whether the component is hidden.
477
+ * @param {(key: string) => string} translate Translation lookup.
478
+ * @returns {string}
479
+ */
480
+ static #renderVisibilityToggle(designator, sourceType, hidden, translate) {
481
+ const label = hidden
482
+ ? translate('scene3d.showSelectedComponent')
483
+ : translate('scene3d.hideSelectedComponent')
484
+ const iconClass = hidden
485
+ ? 'scene-3d__selection-eye-icon scene-3d__selection-eye-off-icon'
486
+ : 'scene-3d__selection-eye-icon'
487
+
488
+ return (
489
+ '<button class="scene-3d__selection-visibility" type="button" data-scene-3d-component-visibility="' +
490
+ PcbScene3dSelectionInspectorRenderer.#escapeHtml(designator) +
491
+ '" data-scene-3d-component-source="' +
492
+ PcbScene3dSelectionInspectorRenderer.#escapeHtml(sourceType) +
493
+ '" aria-label="' +
494
+ PcbScene3dSelectionInspectorRenderer.#escapeHtml(label) +
495
+ '" title="' +
496
+ PcbScene3dSelectionInspectorRenderer.#escapeHtml(label) +
497
+ '" aria-pressed="' +
498
+ (hidden ? 'true' : 'false') +
499
+ '"><span class="' +
500
+ iconClass +
501
+ '" aria-hidden="true">' +
502
+ PcbScene3dSelectionInspectorRenderer.#renderVisibilityIcon(
503
+ !hidden
504
+ ) +
505
+ '</span></button>'
506
+ )
507
+ }
508
+
509
+ /**
510
+ * Renders an eye icon for component visibility state.
511
+ * @param {boolean} visible Whether the component is visible.
512
+ * @returns {string}
513
+ */
514
+ static #renderVisibilityIcon(visible) {
515
+ const slash = visible ? '' : '<path d="M4 4l16 16" />'
516
+ return (
517
+ '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">' +
518
+ '<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z" />' +
519
+ '<circle cx="12" cy="12" r="2.5" />' +
520
+ slash +
521
+ '</svg>'
522
+ )
523
+ }
524
+
464
525
  /**
465
526
  * Formats a model file label.
466
527
  * @param {any} externalPlacement External placement.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Binds the selected-component visibility toggle in the inspector.
3
+ */
4
+ export class PcbScene3dSelectionVisibilityBinder {
5
+ /** @type {HTMLElement | null} */
6
+ #selectionNode
7
+
8
+ /** @type {{ node: EventTarget, type: string, listener: (event: any) => void } | null} */
9
+ #listenerRecord
10
+
11
+ /** @type {() => string} */
12
+ #resolveDesignator
13
+
14
+ /** @type {(designator: string) => boolean} */
15
+ #resolveHidden
16
+
17
+ /** @type {(designator: string, hidden: boolean) => void} */
18
+ #setHidden
19
+
20
+ /** @type {(designator: string, sourceType: string) => void} */
21
+ #refreshSelection
22
+
23
+ /**
24
+ * @param {{ resolveDesignator: () => string, resolveHidden: (designator: string) => boolean, setHidden: (designator: string, hidden: boolean) => void, refreshSelection: (designator: string, sourceType: string) => void }} options Binder callbacks.
25
+ */
26
+ constructor(options) {
27
+ this.#selectionNode = null
28
+ this.#listenerRecord = null
29
+ this.#resolveDesignator = options.resolveDesignator
30
+ this.#resolveHidden = options.resolveHidden
31
+ this.#setHidden = options.setHidden
32
+ this.#refreshSelection = options.refreshSelection
33
+ }
34
+
35
+ /**
36
+ * Binds delegated visibility toggles in the selection inspector.
37
+ * @param {HTMLElement | null} selectionNode Inspector node.
38
+ * @returns {void}
39
+ */
40
+ bindSelectionNode(selectionNode) {
41
+ this.dispose()
42
+ this.#selectionNode =
43
+ selectionNode && typeof selectionNode === 'object'
44
+ ? selectionNode
45
+ : null
46
+ if (!this.#selectionNode) {
47
+ return
48
+ }
49
+
50
+ const listener = (event) => this.#handleClick(event)
51
+ this.#selectionNode.addEventListener?.('click', listener)
52
+ this.#listenerRecord = {
53
+ node: this.#selectionNode,
54
+ type: 'click',
55
+ listener
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Removes the active delegated click listener.
61
+ * @returns {void}
62
+ */
63
+ dispose() {
64
+ if (this.#listenerRecord) {
65
+ const { node, type, listener } = this.#listenerRecord
66
+ node.removeEventListener?.(type, listener)
67
+ }
68
+ this.#listenerRecord = null
69
+ this.#selectionNode = null
70
+ }
71
+
72
+ /**
73
+ * Handles one delegated visibility-button click.
74
+ * @param {any} event DOM event.
75
+ * @returns {void}
76
+ */
77
+ #handleClick(event) {
78
+ const button =
79
+ PcbScene3dSelectionVisibilityBinder.#closestVisibilityButton(
80
+ event?.target
81
+ )
82
+ if (!button) {
83
+ return
84
+ }
85
+
86
+ event?.preventDefault?.()
87
+ const designator = String(
88
+ button?.getAttribute?.('data-scene-3d-component-visibility') ||
89
+ this.#resolveDesignator() ||
90
+ ''
91
+ ).trim()
92
+ if (!designator) {
93
+ return
94
+ }
95
+
96
+ this.#setHidden(designator, !this.#resolveHidden(designator))
97
+ this.#refreshSelection(
98
+ designator,
99
+ String(
100
+ button?.getAttribute?.('data-scene-3d-component-source') || ''
101
+ )
102
+ )
103
+ }
104
+
105
+ /**
106
+ * Finds a visibility button from an event target.
107
+ * @param {any} target Event target.
108
+ * @returns {any | null}
109
+ */
110
+ static #closestVisibilityButton(target) {
111
+ const closest = target?.closest?.(
112
+ '[data-scene-3d-component-visibility]'
113
+ )
114
+ return closest ||
115
+ target?.getAttribute?.('data-scene-3d-component-visibility')
116
+ ? closest || target
117
+ : null
118
+ }
119
+ }
@@ -22,6 +22,7 @@ const FALLBACK_MESSAGES = {
22
22
  'scene3d.fallbackBody': 'Fallback body',
23
23
  'scene3d.fallbackBodies': 'Fallback bodies',
24
24
  'scene3d.footprint': 'Footprint',
25
+ 'scene3d.hideSelectedComponent': 'Hide selected component',
25
26
  'scene3d.inspectPrompt': 'Click a component to inspect it.',
26
27
  'scene3d.interactiveViewAria': 'Interactive 3D PCB view',
27
28
  'scene3d.isometric': 'Isometric',
@@ -39,11 +40,12 @@ const FALLBACK_MESSAGES = {
39
40
  'scene3d.picked': 'Picked',
40
41
  'scene3d.placements': 'Placements',
41
42
  'scene3d.pointerHint':
42
- 'Drag to orbit, right-drag to pan, and use the wheel to zoom.',
43
+ 'Drag pans in Top/Bottom and orbits in Isometric; right-drag uses the alternate action. Use the wheel to zoom.',
43
44
  'scene3d.offset': 'Offset',
44
45
  'scene3d.resetTransform': 'Reset',
45
46
  'scene3d.rotation': 'Rotation',
46
47
  'scene3d.scale': 'Scale',
48
+ 'scene3d.showSelectedComponent': 'Show selected component',
47
49
  'scene3d.skipped': 'Skipped',
48
50
  'scene3d.source': 'Source',
49
51
  'scene3d.startFailed': '3D preview could not start:',
@@ -53,7 +55,7 @@ const FALLBACK_MESSAGES = {
53
55
  'scene3d.toolbarAria': '3D camera presets',
54
56
  'scene3d.top': 'Top',
55
57
  'scene3d.touchHint':
56
- 'Drag with one finger to orbit, pinch to zoom, and drag with two fingers to pan.',
58
+ 'One-finger drag pans in Top/Bottom and orbits in Isometric. Pinch to zoom.',
57
59
  'scene3d.unresolved': 'unresolved'
58
60
  }
59
61
 
package/src/scene3d.mjs CHANGED
@@ -17,6 +17,7 @@ export { PcbScene3dBoardSolderMaskFactory } from './PcbScene3dBoardSolderMaskFac
17
17
  export { PcbScene3dCameraRig } from './PcbScene3dCameraRig.mjs'
18
18
  export { PcbScene3dController } from './PcbScene3dController.mjs'
19
19
  export { PcbScene3dCircuitJsonAdapter } from './PcbScene3dCircuitJsonAdapter.mjs'
20
+ export { PcbScene3dComponentVisibility } from './PcbScene3dComponentVisibility.mjs'
20
21
  export { PcbScene3dCopperDetailFilter } from './PcbScene3dCopperDetailFilter.mjs'
21
22
  export { PcbScene3dCopperFactory } from './PcbScene3dCopperFactory.mjs'
22
23
  export { PcbScene3dCopperTextFactory } from './PcbScene3dCopperTextFactory.mjs'
@@ -34,9 +35,11 @@ export { PcbScene3dOutlineBuilder } from './PcbScene3dOutlineBuilder.mjs'
34
35
  export { PcbScene3dPadFactory } from './PcbScene3dPadFactory.mjs'
35
36
  export { PcbScene3dPresetState } from './PcbScene3dPresetState.mjs'
36
37
  export { PcbScene3dRenderGroupVisibility } from './PcbScene3dRenderGroupVisibility.mjs'
38
+ export { PcbScene3dRenderScheduler } from './PcbScene3dRenderScheduler.mjs'
37
39
  export { PcbScene3dRuntime } from './PcbScene3dRuntime.mjs'
38
40
  export { PcbScene3dRuntimeBoardMeshes } from './PcbScene3dRuntimeBoardMeshes.mjs'
39
41
  export { PcbScene3dSelectionStyler } from './PcbScene3dSelectionStyler.mjs'
42
+ export { PcbScene3dSelectionVisibilityBinder } from './PcbScene3dSelectionVisibilityBinder.mjs'
40
43
  export { PcbScene3dShapePathFactory } from './PcbScene3dShapePathFactory.mjs'
41
44
  export { PcbScene3dShellRenderer } from './PcbScene3dShellRenderer.mjs'
42
45
  export { PcbScene3dSilkscreenFactory } from './PcbScene3dSilkscreenFactory.mjs'
@@ -173,11 +173,59 @@ body.is-viewer-mode.is-viewer-3d .scene-3d {
173
173
  border: 1px solid rgba(95, 112, 124, 0.12);
174
174
  }
175
175
 
176
+ .scene-3d__selection-header {
177
+ display: grid;
178
+ grid-template-columns: minmax(0, 1fr) 2.1rem;
179
+ align-items: center;
180
+ gap: 0.5rem;
181
+ }
182
+
176
183
  .scene-3d__selection-title {
177
184
  margin: 0;
178
185
  font-size: 0.9rem;
179
186
  }
180
187
 
188
+ .scene-3d__selection-visibility {
189
+ display: inline-flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ width: 2.1rem;
193
+ height: 2.1rem;
194
+ border: 1px solid rgba(95, 112, 124, 0.16);
195
+ border-radius: 8px;
196
+ background: rgba(255, 255, 255, 0.78);
197
+ color: #425d70;
198
+ cursor: pointer;
199
+ }
200
+
201
+ .scene-3d__selection-visibility:hover,
202
+ .scene-3d__selection-visibility[aria-pressed='true'] {
203
+ border-color: rgba(184, 90, 37, 0.34);
204
+ color: var(--accent);
205
+ }
206
+
207
+ .scene-3d__selection-eye-icon {
208
+ width: 1.1rem;
209
+ height: 1.1rem;
210
+ display: inline-flex;
211
+ align-items: center;
212
+ justify-content: center;
213
+ }
214
+
215
+ .scene-3d__selection-eye-icon .icon {
216
+ width: 1.05rem;
217
+ height: 1.05rem;
218
+ }
219
+
220
+ .scene-3d__selection-eye-icon .icon path,
221
+ .scene-3d__selection-eye-icon .icon circle {
222
+ fill: none;
223
+ stroke: currentColor;
224
+ stroke-width: 1.8;
225
+ stroke-linecap: round;
226
+ stroke-linejoin: round;
227
+ }
228
+
181
229
  .scene-3d__selection-empty {
182
230
  margin: 0;
183
231
  color: var(--muted);