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.
- package/AGENTS.md +15 -0
- package/package.json +1 -1
- package/src/PcbAssemblyComponentMeshBuilder.mjs +6 -3
- package/src/PcbAssemblyGeometryBuilder.mjs +13 -7
- package/src/PcbAssemblyGltfImageDataUri.mjs +67 -0
- package/src/PcbAssemblyGltfSceneCamera.mjs +450 -0
- package/src/PcbAssemblyGltfWriter.mjs +34 -33
- package/src/PcbScene3dCompanionBasePlacementAdjuster.mjs +8 -0
- package/src/PcbScene3dController.mjs +9 -5
- package/src/PcbScene3dDrillCutoutFilter.mjs +136 -116
- package/src/PcbScene3dExternalCompanionFallback.mjs +48 -3
- package/src/PcbScene3dExternalModelCenteringPolicy.mjs +5 -4
- package/src/PcbScene3dExternalModelDisplayOutlineTarget.mjs +297 -0
- package/src/PcbScene3dExternalModelExactOwnerAnchor.mjs +60 -0
- package/src/PcbScene3dExternalModelGroupLoader.mjs +288 -0
- package/src/PcbScene3dExternalModelOpacity.mjs +6 -0
- package/src/PcbScene3dExternalModelOwnerAnchoredConnectorContactRowRepair.mjs +663 -0
- package/src/PcbScene3dExternalModelPadAnchoredBodyCandidate.mjs +273 -0
- package/src/PcbScene3dExternalModelPadFallbackCenterRepair.mjs +208 -0
- package/src/PcbScene3dExternalModelPlacementRepair.mjs +16 -79
- package/src/PcbScene3dExternalModelRepeatedBoundsCenterRepair.mjs +866 -0
- package/src/PcbScene3dExternalModelRepeatedOwnerPackageCenterRepair.mjs +553 -0
- package/src/PcbScene3dExternalModels.mjs +19 -271
- package/src/PcbScene3dExternalPlacementDefaults.mjs +241 -0
- package/src/PcbScene3dModelSeatingPolicy.mjs +400 -23
- package/src/PcbScene3dRuntime.mjs +8 -6
- package/src/PcbScene3dRuntimeBoardMeshes.mjs +10 -3
- package/src/PcbScene3dStaticBodyFactory.mjs +8 -0
- 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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
renderSceneDescription,
|
|
34
38
|
progress
|
|
35
39
|
)
|
|
36
40
|
const silkscreenMeshes =
|
|
37
41
|
await PcbAssemblyGeometryBuilder.#buildSilkscreenMeshes(
|
|
38
|
-
|
|
42
|
+
renderSceneDescription,
|
|
39
43
|
progress
|
|
40
44
|
)
|
|
41
45
|
const pasteMeshes = await PcbAssemblyGeometryBuilder.#buildPasteMeshes(
|
|
42
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|