pcb-scene3d-viewer 1.1.19 → 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.
@@ -0,0 +1,144 @@
1
+ import { PcbAssemblyExportCoordinateFrame } from './PcbAssemblyExportCoordinateFrame.mjs'
2
+ import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
3
+
4
+ /**
5
+ * Writes PCB assembly meshes as VRML 2.0 WRL.
6
+ */
7
+ export class PcbAssemblyWrlWriter {
8
+ /**
9
+ * Writes meshes to WRL text.
10
+ * @param {{ name?: string, meshes?: object[] }} assembly Assembly data.
11
+ * @returns {string}
12
+ */
13
+ static write(assembly = {}) {
14
+ const meshes = Array.isArray(assembly?.meshes) ? assembly.meshes : []
15
+
16
+ return (
17
+ '#VRML V2.0 utf8\n' +
18
+ 'WorldInfo { title "' +
19
+ PcbAssemblyWrlWriter.#escapeString(assembly?.name || 'assembly') +
20
+ '" }\n' +
21
+ 'Group {\n children [\n' +
22
+ meshes.map((mesh) => PcbAssemblyWrlWriter.#shape(mesh)).join('') +
23
+ ' ]\n}\n'
24
+ )
25
+ }
26
+
27
+ /**
28
+ * Writes one mesh shape.
29
+ * @param {{ name?: string, vertices?: number[][], faces?: number[][], color?: number[] }} mesh Mesh data.
30
+ * @returns {string}
31
+ */
32
+ static #shape(mesh) {
33
+ const vertices = Array.isArray(mesh?.vertices) ? mesh.vertices : []
34
+ const faces = Array.isArray(mesh?.faces) ? mesh.faces : []
35
+ if (!vertices.length || !faces.length) {
36
+ return ''
37
+ }
38
+
39
+ return (
40
+ ' DEF ' +
41
+ PcbAssemblyWrlWriter.#safeVrmlName(mesh?.name || 'mesh') +
42
+ ' Shape {\n' +
43
+ ' appearance Appearance { material Material { diffuseColor ' +
44
+ PcbAssemblyWrlWriter.#color(mesh?.color) +
45
+ ' } }\n' +
46
+ ' geometry IndexedFaceSet {\n' +
47
+ ' solid TRUE\n' +
48
+ ' coord Coordinate { point [\n' +
49
+ vertices
50
+ .map(
51
+ (vertex) =>
52
+ ' ' + PcbAssemblyWrlWriter.#point(vertex)
53
+ )
54
+ .join('') +
55
+ ' ] }\n' +
56
+ ' coordIndex [\n' +
57
+ faces
58
+ .map(
59
+ (face) =>
60
+ ' ' +
61
+ PcbAssemblyWrlWriter.#faceIndices(face) +
62
+ ' -1,\n'
63
+ )
64
+ .join('') +
65
+ ' ]\n' +
66
+ ' }\n' +
67
+ ' }\n'
68
+ )
69
+ }
70
+
71
+ /**
72
+ * Formats one exported coordinate point.
73
+ * @param {number[]} vertex Internal mesh vertex in mils.
74
+ * @returns {string}
75
+ */
76
+ static #point(vertex) {
77
+ return (
78
+ PcbAssemblyExportCoordinateFrame.vertexMilToMm(vertex)
79
+ .map((value) => PcbAssemblyWrlWriter.#formatNumber(value))
80
+ .join(' ') + ',\n'
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Formats one face index list.
86
+ * @param {number[]} face Face indices.
87
+ * @returns {string}
88
+ */
89
+ static #faceIndices(face) {
90
+ return (Array.isArray(face) ? face : [])
91
+ .map((index) => String(Number(index || 0)))
92
+ .join(' ')
93
+ }
94
+
95
+ /**
96
+ * Formats one RGB color.
97
+ * @param {number[] | undefined} color RGB color.
98
+ * @returns {string}
99
+ */
100
+ static #color(color) {
101
+ const normalized = Array.isArray(color) ? color : [0.55, 0.56, 0.58]
102
+ return [0, 1, 2]
103
+ .map((index) =>
104
+ PcbAssemblyWrlWriter.#formatNumber(
105
+ Math.max(Math.min(Number(normalized[index] || 0), 1), 0)
106
+ )
107
+ )
108
+ .join(' ')
109
+ }
110
+
111
+ /**
112
+ * Formats a compact numeric literal.
113
+ * @param {number} value Numeric value.
114
+ * @returns {string}
115
+ */
116
+ static #formatNumber(value) {
117
+ const number = Number(value || 0)
118
+ return Math.abs(number) < 0.0000001
119
+ ? '0'
120
+ : Number(number.toFixed(6)).toString()
121
+ }
122
+
123
+ /**
124
+ * Escapes a WRL string.
125
+ * @param {string} value Source value.
126
+ * @returns {string}
127
+ */
128
+ static #escapeString(value) {
129
+ return String(value || '').replaceAll('"', '\\"')
130
+ }
131
+
132
+ /**
133
+ * Builds a valid VRML DEF token.
134
+ * @param {string} value Source value.
135
+ * @returns {string}
136
+ */
137
+ static #safeVrmlName(value) {
138
+ const safe = PcbAssemblyMeshUtils.safeName(value).replace(
139
+ /[^A-Za-z0-9_]+/gu,
140
+ '_'
141
+ )
142
+ return /^[A-Za-z_]/u.test(safe) ? safe : '_' + safe
143
+ }
144
+ }
@@ -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
+ }