pcb-scene3d-viewer 1.1.44 → 1.1.47

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 (29) hide show
  1. package/AGENTS.md +15 -0
  2. package/package.json +1 -1
  3. package/src/PcbAssemblyComponentMeshBuilder.mjs +6 -3
  4. package/src/PcbAssemblyGeometryBuilder.mjs +13 -7
  5. package/src/PcbAssemblyGltfImageDataUri.mjs +67 -0
  6. package/src/PcbAssemblyGltfSceneCamera.mjs +450 -0
  7. package/src/PcbAssemblyGltfWriter.mjs +34 -33
  8. package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +8 -0
  9. package/src/PcbScene3dController.mjs +9 -5
  10. package/src/PcbScene3dDrillCutoutFilter.mjs +136 -116
  11. package/src/PcbScene3dExternalCompanionFallback.mjs +48 -3
  12. package/src/PcbScene3dExternalModelCenteringPolicy.mjs +5 -4
  13. package/src/PcbScene3dExternalModelDisplayOutlineTarget.mjs +297 -0
  14. package/src/PcbScene3dExternalModelExactOwnerAnchor.mjs +60 -0
  15. package/src/PcbScene3dExternalModelGroupLoader.mjs +288 -0
  16. package/src/PcbScene3dExternalModelOpacity.mjs +6 -0
  17. package/src/PcbScene3dExternalModelOwnerAnchoredConnectorContactRowRepair.mjs +663 -0
  18. package/src/PcbScene3dExternalModelPadAnchoredBodyCandidate.mjs +273 -0
  19. package/src/PcbScene3dExternalModelPadFallbackCenterRepair.mjs +208 -0
  20. package/src/PcbScene3dExternalModelPlacementRepair.mjs +16 -79
  21. package/src/PcbScene3dExternalModelRepeatedBoundsCenterRepair.mjs +866 -0
  22. package/src/PcbScene3dExternalModelRepeatedOwnerPackageCenterRepair.mjs +553 -0
  23. package/src/PcbScene3dExternalModels.mjs +19 -271
  24. package/src/PcbScene3dExternalPlacementDefaults.mjs +241 -0
  25. package/src/PcbScene3dModelSeatingPolicy.mjs +400 -23
  26. package/src/PcbScene3dRuntime.mjs +8 -6
  27. package/src/PcbScene3dRuntimeBoardMeshes.mjs +10 -3
  28. package/src/PcbScene3dStaticBodyFactory.mjs +8 -0
  29. package/src/PcbScene3dTransparentMountFaceCuller.mjs +255 -0
package/AGENTS.md CHANGED
@@ -44,6 +44,21 @@
44
44
  - Keep renderer fixes universal. Never special-case a specific file name,
45
45
  project identifier, fixture helper, or source-derived phrase.
46
46
 
47
+ ## Fix Quality Rules
48
+
49
+ - Fixes must always address the general behavior, not a specific example,
50
+ fixture, file, project, or test case.
51
+ - Do not implement workarounds, cheats, allowlists, hard-coded example
52
+ handling, or special-case logic to make one sample pass.
53
+ - When you encounter existing workaround code, cheating behavior, or
54
+ example-specific handling, rewrite it into general-purpose behavior
55
+ immediately when it is in scope for the change.
56
+ - Keep fixes universal and structural: derive behavior from the underlying data
57
+ model, format, protocol, or UI contract instead of matching known sample text,
58
+ filenames, labels, or project identifiers.
59
+ - After fixing code, run the appropriate repo-owned tests and do not modify
60
+ tests just to make a workaround pass.
61
+
47
62
  ## Testing Guidelines
48
63
 
49
64
  - Use repo scripts only: `npm test`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pcb-scene3d-viewer",
3
- "version": "1.1.44",
3
+ "version": "1.1.47",
4
4
  "description": "Reusable Three.js PCB 3D scene viewer for normalized ECAD and CircuitJSON scene descriptions",
5
5
  "keywords": [
6
6
  "pcb",
@@ -1,4 +1,5 @@
1
1
  import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
2
+ import { PcbScene3dExternalPlacementDefaults } from './PcbScene3dExternalPlacementDefaults.mjs'
2
3
  import { PcbScene3dFootprintBodyBuilder } from './PcbScene3dFootprintBodyBuilder.mjs'
3
4
 
4
5
  const COMPONENT_COLOR = [0.55, 0.56, 0.58]
@@ -15,10 +16,12 @@ export class PcbAssemblyComponentMeshBuilder {
15
16
  * @returns {Promise<{ meshes: object[], diagnostics: object[] }>}
16
17
  */
17
18
  static async build(sceneDescription, options = {}, progress = null) {
19
+ const renderSceneDescription =
20
+ PcbScene3dExternalPlacementDefaults.apply(sceneDescription)
18
21
  const diagnostics = []
19
22
  const meshes =
20
23
  PcbAssemblyComponentMeshBuilder.#buildFallbackComponentMeshes(
21
- sceneDescription,
24
+ renderSceneDescription,
22
25
  options
23
26
  )
24
27
  const loader =
@@ -29,7 +32,7 @@ export class PcbAssemblyComponentMeshBuilder {
29
32
  if (loader && options.includeModels !== false) {
30
33
  meshes.push(
31
34
  ...(await PcbAssemblyComponentMeshBuilder.#buildModelMeshes(
32
- sceneDescription,
35
+ renderSceneDescription,
33
36
  loader,
34
37
  diagnostics,
35
38
  progress
@@ -39,7 +42,7 @@ export class PcbAssemblyComponentMeshBuilder {
39
42
 
40
43
  if (options.includeModels !== false) {
41
44
  PcbAssemblyComponentMeshBuilder.#appendMissingModelDiagnostics(
42
- sceneDescription,
45
+ renderSceneDescription,
43
46
  diagnostics
44
47
  )
45
48
  }
@@ -3,6 +3,7 @@ import { PcbAssemblyComponentMeshBuilder } from './PcbAssemblyComponentMeshBuild
3
3
  import { PcbAssemblyFillGeometryResolver } from './PcbAssemblyFillGeometryResolver.mjs'
4
4
  import { PcbAssemblyMeshUtils } from './PcbAssemblyMeshUtils.mjs'
5
5
  import { PcbAssemblyPadMeshBuilder } from './PcbAssemblyPadMeshBuilder.mjs'
6
+ import { PcbScene3dExternalPlacementDefaults } from './PcbScene3dExternalPlacementDefaults.mjs'
6
7
 
7
8
  const COPPER_THICKNESS_MIL = 2.2
8
9
  const SILKSCREEN_THICKNESS_MIL = 0.8
@@ -23,23 +24,26 @@ export class PcbAssemblyGeometryBuilder {
23
24
  * @returns {Promise<{ meshes: object[], diagnostics: object[] }>}
24
25
  */
25
26
  static async build(sceneDescription, options = {}) {
27
+ const renderSceneDescription =
28
+ PcbScene3dExternalPlacementDefaults.apply(sceneDescription)
26
29
  const diagnostics = []
27
30
  const progress = options.progress || null
28
- const boardMeshes =
29
- PcbAssemblyGeometryBuilder.#buildBoardMeshes(sceneDescription)
31
+ const boardMeshes = PcbAssemblyGeometryBuilder.#buildBoardMeshes(
32
+ renderSceneDescription
33
+ )
30
34
  await progress?.advance?.(1, 'Building board substrate')
31
35
  const copperMeshes =
32
36
  await PcbAssemblyGeometryBuilder.#buildCopperMeshes(
33
- sceneDescription,
37
+ renderSceneDescription,
34
38
  progress
35
39
  )
36
40
  const silkscreenMeshes =
37
41
  await PcbAssemblyGeometryBuilder.#buildSilkscreenMeshes(
38
- sceneDescription,
42
+ renderSceneDescription,
39
43
  progress
40
44
  )
41
45
  const pasteMeshes = await PcbAssemblyGeometryBuilder.#buildPasteMeshes(
42
- sceneDescription,
46
+ renderSceneDescription,
43
47
  progress
44
48
  )
45
49
  const pcbMeshes = [
@@ -49,7 +53,7 @@ export class PcbAssemblyGeometryBuilder {
49
53
  ...silkscreenMeshes
50
54
  ]
51
55
  const componentResult = await PcbAssemblyComponentMeshBuilder.build(
52
- sceneDescription,
56
+ renderSceneDescription,
53
57
  options,
54
58
  progress
55
59
  )
@@ -57,7 +61,9 @@ export class PcbAssemblyGeometryBuilder {
57
61
  const meshes = [
58
62
  ...PcbAssemblyGeometryBuilder.#translateMeshes(
59
63
  pcbMeshes,
60
- PcbAssemblyGeometryBuilder.#boardLocalOffset(sceneDescription)
64
+ PcbAssemblyGeometryBuilder.#boardLocalOffset(
65
+ renderSceneDescription
66
+ )
61
67
  ),
62
68
  ...componentResult.meshes
63
69
  ]
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Parses and packs GLTF image data URIs.
3
+ */
4
+ export class PcbAssemblyGltfImageDataUri {
5
+ /**
6
+ * Packs embeddable image data URI payloads into buffer views.
7
+ * @param {object[]} images Source GLTF image records.
8
+ * @param {(bytes: Uint8Array) => number} appendBufferView Appends bytes and returns a buffer view index.
9
+ * @returns {object[]} Packed image records.
10
+ */
11
+ static pack(images, appendBufferView) {
12
+ return images.map((image) => {
13
+ const parsed = PcbAssemblyGltfImageDataUri.parse(image?.uri)
14
+ if (!parsed) {
15
+ return image
16
+ }
17
+
18
+ return {
19
+ mimeType: parsed.mimeType,
20
+ bufferView: appendBufferView(parsed.bytes)
21
+ }
22
+ })
23
+ }
24
+
25
+ /**
26
+ * Parses one image data URI into MIME type and bytes.
27
+ * @param {string} uri Candidate data URI.
28
+ * @returns {{ mimeType: string, bytes: Uint8Array } | null}
29
+ */
30
+ static parse(uri) {
31
+ const text = String(uri || '')
32
+ const commaIndex = text.indexOf(',')
33
+ if (!text.startsWith('data:') || commaIndex < 0) {
34
+ return null
35
+ }
36
+
37
+ const metadata = text.slice(5, commaIndex).split(';')
38
+ const mimeType = String(metadata[0] || '').toLowerCase()
39
+ if (!mimeType.startsWith('image/')) {
40
+ return null
41
+ }
42
+
43
+ const payload = text.slice(commaIndex + 1)
44
+ const bytes = metadata.includes('base64')
45
+ ? PcbAssemblyGltfImageDataUri.#base64Bytes(payload)
46
+ : new TextEncoder().encode(decodeURIComponent(payload))
47
+ return { mimeType, bytes }
48
+ }
49
+
50
+ /**
51
+ * Decodes a base64 payload into bytes in browser and Node runtimes.
52
+ * @param {string} value Base64 payload.
53
+ * @returns {Uint8Array}
54
+ */
55
+ static #base64Bytes(value) {
56
+ if (typeof Buffer !== 'undefined') {
57
+ return new Uint8Array(Buffer.from(value, 'base64'))
58
+ }
59
+
60
+ const binary = atob(value)
61
+ const bytes = new Uint8Array(binary.length)
62
+ for (let index = 0; index < binary.length; index += 1) {
63
+ bytes[index] = binary.charCodeAt(index)
64
+ }
65
+ return bytes
66
+ }
67
+ }
@@ -0,0 +1,450 @@
1
+ const DEFAULT_ASPECT_RATIO = 1
2
+ const DEFAULT_YFOV = 0.7
3
+ const FIT_MARGIN = 1.18
4
+
5
+ /**
6
+ * Resolves fitted GLTF camera metadata for exported PCB assemblies.
7
+ */
8
+ export class PcbAssemblyGltfSceneCamera {
9
+ /**
10
+ * Resolves a perspective camera pose and projection from scene bounds.
11
+ * @param {{ min?: number[], max?: number[] }} bounds Exported scene bounds.
12
+ * @param {{ sceneCameraPreset?: string, sceneCameraAspectRatio?: number, sceneCameraFovDegrees?: number }} [options] Camera options.
13
+ * @returns {{ center: number[], distance: number, position: number[], rotation: number[], perspective: object }}
14
+ */
15
+ static resolve(bounds, options = {}) {
16
+ const normalizedBounds = PcbAssemblyGltfSceneCamera.#bounds(bounds)
17
+ const center = PcbAssemblyGltfSceneCamera.#center(normalizedBounds)
18
+ const frame = PcbAssemblyGltfSceneCamera.#viewFrame(
19
+ options.sceneCameraPreset
20
+ )
21
+ const yfov = PcbAssemblyGltfSceneCamera.#yfov(
22
+ options.sceneCameraFovDegrees
23
+ )
24
+ const aspectRatio = PcbAssemblyGltfSceneCamera.#aspectRatio(
25
+ options.sceneCameraAspectRatio
26
+ )
27
+ const distance = PcbAssemblyGltfSceneCamera.#fitDistance(
28
+ normalizedBounds,
29
+ center,
30
+ frame,
31
+ yfov,
32
+ aspectRatio
33
+ )
34
+ const position = center.map(
35
+ (value, index) => value + frame.backward[index] * distance
36
+ )
37
+ const perspective = {
38
+ yfov,
39
+ znear: Math.max(distance / 1000, 0.01),
40
+ zfar: Math.max(distance * 10, distance + 10)
41
+ }
42
+
43
+ if (PcbAssemblyGltfSceneCamera.#hasAspectRatioOption(options)) {
44
+ perspective.aspectRatio = aspectRatio
45
+ }
46
+
47
+ return {
48
+ center,
49
+ distance,
50
+ position,
51
+ rotation: PcbAssemblyGltfSceneCamera.#lookRotation(
52
+ position,
53
+ center,
54
+ frame.up
55
+ ),
56
+ perspective
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Normalizes scene bounds.
62
+ * @param {{ min?: number[], max?: number[] }} bounds Candidate bounds.
63
+ * @returns {{ min: number[], max: number[] }}
64
+ */
65
+ static #bounds(bounds) {
66
+ const min = Array.isArray(bounds?.min) ? bounds.min : []
67
+ const max = Array.isArray(bounds?.max) ? bounds.max : []
68
+ const normalized = {
69
+ min: [0, 1, 2].map((index) =>
70
+ Number.isFinite(Number(min[index])) ? Number(min[index]) : -0.5
71
+ ),
72
+ max: [0, 1, 2].map((index) =>
73
+ Number.isFinite(Number(max[index])) ? Number(max[index]) : 0.5
74
+ )
75
+ }
76
+
77
+ for (let index = 0; index < 3; index += 1) {
78
+ if (normalized.min[index] > normalized.max[index]) {
79
+ const value = normalized.min[index]
80
+ normalized.min[index] = normalized.max[index]
81
+ normalized.max[index] = value
82
+ }
83
+ }
84
+
85
+ return normalized
86
+ }
87
+
88
+ /**
89
+ * Computes the bounds center.
90
+ * @param {{ min: number[], max: number[] }} bounds Scene bounds.
91
+ * @returns {number[]}
92
+ */
93
+ static #center(bounds) {
94
+ return [0, 1, 2].map(
95
+ (index) => (bounds.min[index] + bounds.max[index]) / 2
96
+ )
97
+ }
98
+
99
+ /**
100
+ * Resolves a camera view frame from a named preset.
101
+ * @param {string | undefined} preset Candidate preset.
102
+ * @returns {{ backward: number[], up: number[] }}
103
+ */
104
+ static #viewFrame(preset) {
105
+ const normalized = PcbAssemblyGltfSceneCamera.#preset(preset)
106
+ const frames = {
107
+ top: {
108
+ backward: [0, 1, 0],
109
+ up: [0, 0, -1]
110
+ },
111
+ bottom: {
112
+ backward: [0, -1, 0],
113
+ up: [0, 0, 1]
114
+ },
115
+ front: {
116
+ backward: [0, 0, 1],
117
+ up: [0, 1, 0]
118
+ },
119
+ back: {
120
+ backward: [0, 0, -1],
121
+ up: [0, 1, 0]
122
+ },
123
+ right: {
124
+ backward: [1, 0, 0],
125
+ up: [0, 1, 0]
126
+ },
127
+ left: {
128
+ backward: [-1, 0, 0],
129
+ up: [0, 1, 0]
130
+ },
131
+ isometric: {
132
+ backward: PcbAssemblyGltfSceneCamera.#normalize([
133
+ 0.65, 0.45, 1
134
+ ]),
135
+ up: [0, 1, 0]
136
+ }
137
+ }
138
+
139
+ return frames[normalized] || frames.isometric
140
+ }
141
+
142
+ /**
143
+ * Normalizes a camera preset name.
144
+ * @param {string | undefined} preset Candidate preset.
145
+ * @returns {string}
146
+ */
147
+ static #preset(preset) {
148
+ const normalized = String(preset || 'isometric')
149
+ .toLowerCase()
150
+ .replace(/[\s-]+/gu, '_')
151
+ const aliases = {
152
+ top_down: 'top',
153
+ bottom_up: 'bottom',
154
+ left_side: 'left',
155
+ right_side: 'right'
156
+ }
157
+ const value = aliases[normalized] || normalized
158
+
159
+ return [
160
+ 'isometric',
161
+ 'top',
162
+ 'bottom',
163
+ 'front',
164
+ 'back',
165
+ 'left',
166
+ 'right'
167
+ ].includes(value)
168
+ ? value
169
+ : 'isometric'
170
+ }
171
+
172
+ /**
173
+ * Resolves vertical field of view in radians.
174
+ * @param {number | undefined} fovDegrees Candidate field of view.
175
+ * @returns {number}
176
+ */
177
+ static #yfov(fovDegrees) {
178
+ const degrees = Number(fovDegrees)
179
+ if (!Number.isFinite(degrees)) {
180
+ return DEFAULT_YFOV
181
+ }
182
+
183
+ return (Math.min(Math.max(degrees, 10), 100) * Math.PI) / 180
184
+ }
185
+
186
+ /**
187
+ * Resolves camera aspect ratio.
188
+ * @param {number | undefined} aspectRatio Candidate aspect ratio.
189
+ * @returns {number}
190
+ */
191
+ static #aspectRatio(aspectRatio) {
192
+ const value = Number(aspectRatio)
193
+ if (!Number.isFinite(value) || value <= 0) {
194
+ return DEFAULT_ASPECT_RATIO
195
+ }
196
+
197
+ return Math.min(Math.max(value, 0.1), 10)
198
+ }
199
+
200
+ /**
201
+ * Returns true when an explicit aspect ratio should be emitted.
202
+ * @param {object} options Camera options.
203
+ * @returns {boolean}
204
+ */
205
+ static #hasAspectRatioOption(options) {
206
+ const value = Number(options?.sceneCameraAspectRatio)
207
+ return Number.isFinite(value) && value > 0
208
+ }
209
+
210
+ /**
211
+ * Computes a perspective camera distance that frames the scene bounds.
212
+ * @param {{ min: number[], max: number[] }} bounds Scene bounds.
213
+ * @param {number[]} center Scene center.
214
+ * @param {{ backward: number[], up: number[] }} frame Camera frame.
215
+ * @param {number} yfov Vertical field of view in radians.
216
+ * @param {number} aspectRatio Camera aspect ratio.
217
+ * @returns {number}
218
+ */
219
+ static #fitDistance(bounds, center, frame, yfov, aspectRatio) {
220
+ const basis = PcbAssemblyGltfSceneCamera.#basis(
221
+ frame.backward,
222
+ frame.up
223
+ )
224
+ const tanY = Math.tan(yfov / 2)
225
+ const tanX = tanY * Math.max(aspectRatio, 0.0001)
226
+ const requiredDistance = PcbAssemblyGltfSceneCamera.#corners(bounds)
227
+ .map((corner) => {
228
+ const offset = PcbAssemblyGltfSceneCamera.#subtract(
229
+ corner,
230
+ center
231
+ )
232
+ const depth = PcbAssemblyGltfSceneCamera.#dot(
233
+ offset,
234
+ basis.backward
235
+ )
236
+ const horizontal =
237
+ Math.abs(
238
+ PcbAssemblyGltfSceneCamera.#dot(offset, basis.right)
239
+ ) / tanX
240
+ const vertical =
241
+ Math.abs(
242
+ PcbAssemblyGltfSceneCamera.#dot(offset, basis.up)
243
+ ) / tanY
244
+
245
+ return Math.max(depth + horizontal, depth + vertical)
246
+ })
247
+ .reduce((max, value) => Math.max(max, value), 0)
248
+
249
+ return Math.max(
250
+ requiredDistance * FIT_MARGIN,
251
+ PcbAssemblyGltfSceneCamera.#diagonal(bounds) * 0.55,
252
+ 1
253
+ )
254
+ }
255
+
256
+ /**
257
+ * Builds all bounding-box corners.
258
+ * @param {{ min: number[], max: number[] }} bounds Scene bounds.
259
+ * @returns {number[][]}
260
+ */
261
+ static #corners(bounds) {
262
+ const corners = []
263
+ for (const x of [bounds.min[0], bounds.max[0]]) {
264
+ for (const y of [bounds.min[1], bounds.max[1]]) {
265
+ for (const z of [bounds.min[2], bounds.max[2]]) {
266
+ corners.push([x, y, z])
267
+ }
268
+ }
269
+ }
270
+ return corners
271
+ }
272
+
273
+ /**
274
+ * Computes bounds diagonal length.
275
+ * @param {{ min: number[], max: number[] }} bounds Scene bounds.
276
+ * @returns {number}
277
+ */
278
+ static #diagonal(bounds) {
279
+ return Math.hypot(
280
+ bounds.max[0] - bounds.min[0],
281
+ bounds.max[1] - bounds.min[1],
282
+ bounds.max[2] - bounds.min[2]
283
+ )
284
+ }
285
+
286
+ /**
287
+ * Resolves an orthonormal camera basis.
288
+ * @param {number[]} backward Camera backward direction.
289
+ * @param {number[]} upHint Preferred up direction.
290
+ * @returns {{ right: number[], up: number[], backward: number[] }}
291
+ */
292
+ static #basis(backward, upHint) {
293
+ const z = PcbAssemblyGltfSceneCamera.#normalize(backward)
294
+ const fallbackUp = Math.abs(z[1]) > 0.95 ? [0, 0, 1] : [0, 1, 0]
295
+ const candidateUp =
296
+ Math.abs(PcbAssemblyGltfSceneCamera.#dot(z, upHint)) > 0.95
297
+ ? fallbackUp
298
+ : upHint
299
+ const right = PcbAssemblyGltfSceneCamera.#normalize(
300
+ PcbAssemblyGltfSceneCamera.#cross(candidateUp, z)
301
+ )
302
+ const up = PcbAssemblyGltfSceneCamera.#normalize(
303
+ PcbAssemblyGltfSceneCamera.#cross(z, right)
304
+ )
305
+
306
+ return {
307
+ right,
308
+ up,
309
+ backward: z
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Builds a GLTF node quaternion that looks from position to target.
315
+ * @param {number[]} position Camera position.
316
+ * @param {number[]} target Camera target.
317
+ * @param {number[]} upHint Preferred up direction.
318
+ * @returns {number[]}
319
+ */
320
+ static #lookRotation(position, target, upHint) {
321
+ const backward = PcbAssemblyGltfSceneCamera.#normalize(
322
+ PcbAssemblyGltfSceneCamera.#subtract(position, target)
323
+ )
324
+ const basis = PcbAssemblyGltfSceneCamera.#basis(backward, upHint)
325
+
326
+ return PcbAssemblyGltfSceneCamera.#quaternionFromBasis(basis)
327
+ }
328
+
329
+ /**
330
+ * Converts a camera basis into a quaternion.
331
+ * @param {{ right: number[], up: number[], backward: number[] }} basis Camera basis.
332
+ * @returns {number[]}
333
+ */
334
+ static #quaternionFromBasis(basis) {
335
+ const m00 = basis.right[0]
336
+ const m01 = basis.up[0]
337
+ const m02 = basis.backward[0]
338
+ const m10 = basis.right[1]
339
+ const m11 = basis.up[1]
340
+ const m12 = basis.backward[1]
341
+ const m20 = basis.right[2]
342
+ const m21 = basis.up[2]
343
+ const m22 = basis.backward[2]
344
+ const trace = m00 + m11 + m22
345
+ let quaternion
346
+
347
+ if (trace > 0) {
348
+ const scale = Math.sqrt(trace + 1) * 2
349
+ quaternion = [
350
+ (m21 - m12) / scale,
351
+ (m02 - m20) / scale,
352
+ (m10 - m01) / scale,
353
+ 0.25 * scale
354
+ ]
355
+ } else if (m00 > m11 && m00 > m22) {
356
+ const scale = Math.sqrt(1 + m00 - m11 - m22) * 2
357
+ quaternion = [
358
+ 0.25 * scale,
359
+ (m01 + m10) / scale,
360
+ (m02 + m20) / scale,
361
+ (m21 - m12) / scale
362
+ ]
363
+ } else if (m11 > m22) {
364
+ const scale = Math.sqrt(1 + m11 - m00 - m22) * 2
365
+ quaternion = [
366
+ (m01 + m10) / scale,
367
+ 0.25 * scale,
368
+ (m12 + m21) / scale,
369
+ (m02 - m20) / scale
370
+ ]
371
+ } else {
372
+ const scale = Math.sqrt(1 + m22 - m00 - m11) * 2
373
+ quaternion = [
374
+ (m02 + m20) / scale,
375
+ (m12 + m21) / scale,
376
+ 0.25 * scale,
377
+ (m10 - m01) / scale
378
+ ]
379
+ }
380
+
381
+ return PcbAssemblyGltfSceneCamera.#normalizeQuaternion(quaternion)
382
+ }
383
+
384
+ /**
385
+ * Normalizes a quaternion.
386
+ * @param {number[]} quaternion Candidate quaternion.
387
+ * @returns {number[]}
388
+ */
389
+ static #normalizeQuaternion(quaternion) {
390
+ const length = Math.hypot(
391
+ quaternion[0],
392
+ quaternion[1],
393
+ quaternion[2],
394
+ quaternion[3]
395
+ )
396
+ if (!Number.isFinite(length) || length <= 0) {
397
+ return [0, 0, 0, 1]
398
+ }
399
+
400
+ return quaternion.map((value) => value / length)
401
+ }
402
+
403
+ /**
404
+ * Subtracts two vectors.
405
+ * @param {number[]} left Left vector.
406
+ * @param {number[]} right Right vector.
407
+ * @returns {number[]}
408
+ */
409
+ static #subtract(left, right) {
410
+ return [0, 1, 2].map((index) => left[index] - right[index])
411
+ }
412
+
413
+ /**
414
+ * Computes a dot product.
415
+ * @param {number[]} left Left vector.
416
+ * @param {number[]} right Right vector.
417
+ * @returns {number}
418
+ */
419
+ static #dot(left, right) {
420
+ return left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
421
+ }
422
+
423
+ /**
424
+ * Computes a cross product.
425
+ * @param {number[]} left Left vector.
426
+ * @param {number[]} right Right vector.
427
+ * @returns {number[]}
428
+ */
429
+ static #cross(left, right) {
430
+ return [
431
+ left[1] * right[2] - left[2] * right[1],
432
+ left[2] * right[0] - left[0] * right[2],
433
+ left[0] * right[1] - left[1] * right[0]
434
+ ]
435
+ }
436
+
437
+ /**
438
+ * Normalizes a vector.
439
+ * @param {number[]} vector Candidate vector.
440
+ * @returns {number[]}
441
+ */
442
+ static #normalize(vector) {
443
+ const length = Math.hypot(vector[0], vector[1], vector[2])
444
+ if (!Number.isFinite(length) || length <= 0) {
445
+ return [0, 0, 1]
446
+ }
447
+
448
+ return vector.map((value) => value / length)
449
+ }
450
+ }