@chocozhang/three-model-render 1.0.3 → 1.0.5
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/CHANGELOG.md +39 -0
- package/README.md +134 -97
- package/dist/camera/index.d.ts +59 -36
- package/dist/camera/index.js +83 -67
- package/dist/camera/index.js.map +1 -1
- package/dist/camera/index.mjs +83 -67
- package/dist/camera/index.mjs.map +1 -1
- package/dist/core/index.d.ts +81 -28
- package/dist/core/index.js +194 -104
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +194 -105
- package/dist/core/index.mjs.map +1 -1
- package/dist/effect/index.d.ts +47 -134
- package/dist/effect/index.js +287 -288
- package/dist/effect/index.js.map +1 -1
- package/dist/effect/index.mjs +287 -288
- package/dist/effect/index.mjs.map +1 -1
- package/dist/index.d.ts +432 -349
- package/dist/index.js +1399 -1228
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1395 -1229
- package/dist/index.mjs.map +1 -1
- package/dist/interaction/index.d.ts +85 -52
- package/dist/interaction/index.js +168 -142
- package/dist/interaction/index.js.map +1 -1
- package/dist/interaction/index.mjs +168 -142
- package/dist/interaction/index.mjs.map +1 -1
- package/dist/loader/index.d.ts +106 -58
- package/dist/loader/index.js +492 -454
- package/dist/loader/index.js.map +1 -1
- package/dist/loader/index.mjs +491 -455
- package/dist/loader/index.mjs.map +1 -1
- package/dist/setup/index.d.ts +26 -24
- package/dist/setup/index.js +125 -163
- package/dist/setup/index.js.map +1 -1
- package/dist/setup/index.mjs +124 -164
- package/dist/setup/index.mjs.map +1 -1
- package/dist/ui/index.d.ts +18 -7
- package/dist/ui/index.js +45 -37
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/index.mjs +45 -37
- package/dist/ui/index.mjs.map +1 -1
- package/package.json +50 -22
package/dist/index.mjs
CHANGED
|
@@ -8,25 +8,35 @@ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUti
|
|
|
8
8
|
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* @file labelManager.ts
|
|
12
|
+
* @description
|
|
13
|
+
* Manages HTML labels attached to 3D objects. Efficiently updates label positions based on camera movement.
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
17
|
-
|
|
15
|
+
* @best-practice
|
|
16
|
+
* - Use `addChildModelLabels` to label parts of a loaded model.
|
|
17
|
+
* - Labels are HTML elements overlaid on the canvas.
|
|
18
|
+
* - Supports performance optimization via caching and visibility culling.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Add overhead labels to child models (supports Mesh and Group)
|
|
22
|
+
*
|
|
23
|
+
* Features:
|
|
24
|
+
* - Caches bounding boxes to avoid repetitive calculation every frame
|
|
25
|
+
* - Supports pause/resume
|
|
26
|
+
* - Configurable update interval to reduce CPU usage
|
|
27
|
+
* - Automatically pauses when hidden
|
|
18
28
|
*
|
|
19
|
-
* @param camera THREE.Camera -
|
|
20
|
-
* @param renderer THREE.WebGLRenderer -
|
|
21
|
-
* @param parentModel THREE.Object3D - FBX
|
|
22
|
-
* @param modelLabelsMap Record<string,string> -
|
|
23
|
-
* @param options LabelOptions -
|
|
24
|
-
* @returns LabelManager -
|
|
29
|
+
* @param camera THREE.Camera - Scene camera
|
|
30
|
+
* @param renderer THREE.WebGLRenderer - Renderer, used for screen size
|
|
31
|
+
* @param parentModel THREE.Object3D - FBX root node or Group
|
|
32
|
+
* @param modelLabelsMap Record<string,string> - Map of model name to label text
|
|
33
|
+
* @param options LabelOptions - Optional label style configuration
|
|
34
|
+
* @returns LabelManager - Management interface containing pause/resume/dispose
|
|
25
35
|
*/
|
|
26
36
|
function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, options) {
|
|
27
|
-
//
|
|
37
|
+
// Defensive check: ensure parentModel is loaded
|
|
28
38
|
if (!parentModel || typeof parentModel.traverse !== 'function') {
|
|
29
|
-
console.error('parentModel
|
|
39
|
+
console.error('parentModel invalid, please ensure the FBX model is loaded');
|
|
30
40
|
return {
|
|
31
41
|
pause: () => { },
|
|
32
42
|
resume: () => { },
|
|
@@ -34,48 +44,47 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
34
44
|
isRunning: () => false
|
|
35
45
|
};
|
|
36
46
|
}
|
|
37
|
-
//
|
|
38
|
-
const enableCache =
|
|
39
|
-
const updateInterval =
|
|
40
|
-
//
|
|
47
|
+
// Configuration
|
|
48
|
+
const enableCache = options?.enableCache !== false;
|
|
49
|
+
const updateInterval = options?.updateInterval || 0;
|
|
50
|
+
// Create label container, absolute positioning, attached to body
|
|
41
51
|
const container = document.createElement('div');
|
|
42
52
|
container.style.position = 'absolute';
|
|
43
53
|
container.style.top = '0';
|
|
44
54
|
container.style.left = '0';
|
|
45
|
-
container.style.pointerEvents = 'none'; //
|
|
55
|
+
container.style.pointerEvents = 'none'; // Avoid blocking mouse events
|
|
46
56
|
container.style.zIndex = '1000';
|
|
47
57
|
document.body.appendChild(container);
|
|
48
58
|
const labels = [];
|
|
49
|
-
//
|
|
59
|
+
// State management
|
|
50
60
|
let rafId = null;
|
|
51
61
|
let isPaused = false;
|
|
52
62
|
let lastUpdateTime = 0;
|
|
53
|
-
//
|
|
63
|
+
// Traverse all child models
|
|
54
64
|
parentModel.traverse((child) => {
|
|
55
|
-
|
|
56
|
-
// 只处理 Mesh 或 Group
|
|
65
|
+
// Only process Mesh or Group
|
|
57
66
|
if ((child.isMesh || child.type === 'Group')) {
|
|
58
|
-
//
|
|
59
|
-
const labelText =
|
|
67
|
+
// Dynamic matching of name to prevent undefined
|
|
68
|
+
const labelText = Object.entries(modelLabelsMap).find(([key]) => child.name.includes(key))?.[1];
|
|
60
69
|
if (!labelText)
|
|
61
|
-
return; //
|
|
62
|
-
//
|
|
70
|
+
return; // Skip if no matching label
|
|
71
|
+
// Create DOM label
|
|
63
72
|
const el = document.createElement('div');
|
|
64
73
|
el.innerText = labelText;
|
|
65
|
-
//
|
|
74
|
+
// Styles defined in JS, can be overridden via options
|
|
66
75
|
el.style.position = 'absolute';
|
|
67
|
-
el.style.color =
|
|
68
|
-
el.style.background =
|
|
69
|
-
el.style.padding =
|
|
70
|
-
el.style.borderRadius =
|
|
71
|
-
el.style.fontSize =
|
|
72
|
-
el.style.transform = 'translate(-50%, -100%)'; //
|
|
76
|
+
el.style.color = options?.color || '#fff';
|
|
77
|
+
el.style.background = options?.background || 'rgba(0,0,0,0.6)';
|
|
78
|
+
el.style.padding = options?.padding || '4px 8px';
|
|
79
|
+
el.style.borderRadius = options?.borderRadius || '4px';
|
|
80
|
+
el.style.fontSize = options?.fontSize || '14px';
|
|
81
|
+
el.style.transform = 'translate(-50%, -100%)'; // Position label directly above the model
|
|
73
82
|
el.style.whiteSpace = 'nowrap';
|
|
74
83
|
el.style.pointerEvents = 'none';
|
|
75
84
|
el.style.transition = 'opacity 0.2s ease';
|
|
76
|
-
//
|
|
85
|
+
// Append to container
|
|
77
86
|
container.appendChild(el);
|
|
78
|
-
//
|
|
87
|
+
// Initialize cache
|
|
79
88
|
const cachedBox = new THREE.Box3().setFromObject(child);
|
|
80
89
|
const center = new THREE.Vector3();
|
|
81
90
|
cachedBox.getCenter(center);
|
|
@@ -90,7 +99,7 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
90
99
|
}
|
|
91
100
|
});
|
|
92
101
|
/**
|
|
93
|
-
*
|
|
102
|
+
* Update cached bounding box (called only when model transforms)
|
|
94
103
|
*/
|
|
95
104
|
const updateCache = (labelData) => {
|
|
96
105
|
labelData.cachedBox.setFromObject(labelData.object);
|
|
@@ -100,18 +109,18 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
100
109
|
labelData.needsUpdate = false;
|
|
101
110
|
};
|
|
102
111
|
/**
|
|
103
|
-
*
|
|
112
|
+
* Get object top world coordinates (using cache)
|
|
104
113
|
*/
|
|
105
114
|
const getObjectTopPosition = (labelData) => {
|
|
106
115
|
if (enableCache) {
|
|
107
|
-
//
|
|
116
|
+
// Check if object has transformed
|
|
108
117
|
if (labelData.needsUpdate || labelData.object.matrixWorldNeedsUpdate) {
|
|
109
118
|
updateCache(labelData);
|
|
110
119
|
}
|
|
111
120
|
return labelData.cachedTopPos;
|
|
112
121
|
}
|
|
113
122
|
else {
|
|
114
|
-
//
|
|
123
|
+
// Do not use cache, recalculate every time
|
|
115
124
|
const box = new THREE.Box3().setFromObject(labelData.object);
|
|
116
125
|
const center = new THREE.Vector3();
|
|
117
126
|
box.getCenter(center);
|
|
@@ -119,15 +128,15 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
119
128
|
}
|
|
120
129
|
};
|
|
121
130
|
/**
|
|
122
|
-
*
|
|
131
|
+
* Update label positions function
|
|
123
132
|
*/
|
|
124
133
|
function updateLabels(timestamp = 0) {
|
|
125
|
-
//
|
|
134
|
+
// Check pause state
|
|
126
135
|
if (isPaused) {
|
|
127
136
|
rafId = null;
|
|
128
137
|
return;
|
|
129
138
|
}
|
|
130
|
-
//
|
|
139
|
+
// Check update interval
|
|
131
140
|
if (updateInterval > 0 && timestamp - lastUpdateTime < updateInterval) {
|
|
132
141
|
rafId = requestAnimationFrame(updateLabels);
|
|
133
142
|
return;
|
|
@@ -137,22 +146,22 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
137
146
|
const height = renderer.domElement.clientHeight;
|
|
138
147
|
labels.forEach((labelData) => {
|
|
139
148
|
const { el } = labelData;
|
|
140
|
-
const pos = getObjectTopPosition(labelData); //
|
|
141
|
-
pos.project(camera); //
|
|
142
|
-
const x = (pos.x * 0.5 + 0.5) * width; //
|
|
143
|
-
const y = (-(pos.y * 0.5) + 0.5) * height; //
|
|
144
|
-
//
|
|
149
|
+
const pos = getObjectTopPosition(labelData); // Use cached top position
|
|
150
|
+
pos.project(camera); // Convert to screen coordinates
|
|
151
|
+
const x = (pos.x * 0.5 + 0.5) * width; // Screen X
|
|
152
|
+
const y = (-(pos.y * 0.5) + 0.5) * height; // Screen Y
|
|
153
|
+
// Control label visibility (hidden when behind camera)
|
|
145
154
|
const isVisible = pos.z < 1;
|
|
146
155
|
el.style.opacity = isVisible ? '1' : '0';
|
|
147
156
|
el.style.display = isVisible ? 'block' : 'none';
|
|
148
|
-
el.style.transform = `translate(-50%, -100%) translate(${x}px, ${y}px)`; //
|
|
157
|
+
el.style.transform = `translate(-50%, -100%) translate(${x}px, ${y}px)`; // Screen position
|
|
149
158
|
});
|
|
150
|
-
rafId = requestAnimationFrame(updateLabels); //
|
|
159
|
+
rafId = requestAnimationFrame(updateLabels); // Loop update
|
|
151
160
|
}
|
|
152
|
-
//
|
|
161
|
+
// Start update
|
|
153
162
|
updateLabels();
|
|
154
163
|
/**
|
|
155
|
-
*
|
|
164
|
+
* Pause updates
|
|
156
165
|
*/
|
|
157
166
|
const pause = () => {
|
|
158
167
|
isPaused = true;
|
|
@@ -162,7 +171,7 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
162
171
|
}
|
|
163
172
|
};
|
|
164
173
|
/**
|
|
165
|
-
*
|
|
174
|
+
* Resume updates
|
|
166
175
|
*/
|
|
167
176
|
const resume = () => {
|
|
168
177
|
if (!isPaused)
|
|
@@ -171,11 +180,11 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
171
180
|
updateLabels();
|
|
172
181
|
};
|
|
173
182
|
/**
|
|
174
|
-
*
|
|
183
|
+
* Check if running
|
|
175
184
|
*/
|
|
176
185
|
const isRunning = () => !isPaused;
|
|
177
186
|
/**
|
|
178
|
-
*
|
|
187
|
+
* Cleanup function: Remove all DOM labels, cancel animation, avoid memory leaks
|
|
179
188
|
*/
|
|
180
189
|
const dispose = () => {
|
|
181
190
|
pause();
|
|
@@ -197,36 +206,45 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
197
206
|
};
|
|
198
207
|
}
|
|
199
208
|
|
|
200
|
-
// src/utils/hoverBreathEffectByNameSingleton.ts
|
|
201
209
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
210
|
+
* @file hoverEffect.ts
|
|
211
|
+
* @description
|
|
212
|
+
* Singleton highlight effect manager. Uses OutlinePass to create a breathing highlight effect on hovered objects.
|
|
213
|
+
*
|
|
214
|
+
* @best-practice
|
|
215
|
+
* - Initialize once in your setup/mounted hook.
|
|
216
|
+
* - Call `updateHighlightNames` to filter which objects are interactive.
|
|
217
|
+
* - Automatically handles mousemove throttling and cleanup on dispose.
|
|
218
|
+
*/
|
|
219
|
+
/**
|
|
220
|
+
* Create a singleton highlighter - Recommended to create once on mount
|
|
221
|
+
* Returns { updateHighlightNames, dispose, getHoveredName } interface
|
|
204
222
|
*
|
|
205
|
-
*
|
|
206
|
-
* -
|
|
207
|
-
* - mousemove
|
|
208
|
-
* -
|
|
223
|
+
* Features:
|
|
224
|
+
* - Automatically pauses animation when no object is hovered
|
|
225
|
+
* - Throttles mousemove events to avoid excessive calculation
|
|
226
|
+
* - Uses passive event listeners to improve scrolling performance
|
|
209
227
|
*/
|
|
210
228
|
function enableHoverBreath(opts) {
|
|
211
|
-
const { camera, scene, renderer, outlinePass, highlightNames = null, minStrength = 2, maxStrength = 5, speed = 4, throttleDelay = 16, //
|
|
229
|
+
const { camera, scene, renderer, outlinePass, highlightNames = null, minStrength = 2, maxStrength = 5, speed = 4, throttleDelay = 16, // Default ~60fps
|
|
212
230
|
} = opts;
|
|
213
231
|
const raycaster = new THREE.Raycaster();
|
|
214
232
|
const mouse = new THREE.Vector2();
|
|
215
233
|
let hovered = null;
|
|
216
234
|
let time = 0;
|
|
217
235
|
let animationId = null;
|
|
218
|
-
// highlightSet: null
|
|
236
|
+
// highlightSet: null means all; empty Set means none
|
|
219
237
|
let highlightSet = highlightNames === null ? null : new Set(highlightNames);
|
|
220
|
-
//
|
|
238
|
+
// Throttling related
|
|
221
239
|
let lastMoveTime = 0;
|
|
222
240
|
let rafPending = false;
|
|
223
241
|
function setHighlightNames(names) {
|
|
224
242
|
highlightSet = names === null ? null : new Set(names);
|
|
225
|
-
//
|
|
243
|
+
// If current hovered object is not in the new list, clean up selection immediately
|
|
226
244
|
if (hovered && highlightSet && !highlightSet.has(hovered.name)) {
|
|
227
245
|
hovered = null;
|
|
228
246
|
outlinePass.selectedObjects = [];
|
|
229
|
-
//
|
|
247
|
+
// Pause animation
|
|
230
248
|
if (animationId !== null) {
|
|
231
249
|
cancelAnimationFrame(animationId);
|
|
232
250
|
animationId = null;
|
|
@@ -234,13 +252,13 @@ function enableHoverBreath(opts) {
|
|
|
234
252
|
}
|
|
235
253
|
}
|
|
236
254
|
/**
|
|
237
|
-
*
|
|
255
|
+
* Throttled mousemove handler
|
|
238
256
|
*/
|
|
239
257
|
function onMouseMove(ev) {
|
|
240
258
|
const now = performance.now();
|
|
241
|
-
//
|
|
259
|
+
// Throttle: if time since last process is less than threshold, skip
|
|
242
260
|
if (now - lastMoveTime < throttleDelay) {
|
|
243
|
-
//
|
|
261
|
+
// Use RAF to process the latest event later, ensuring the last event isn't lost
|
|
244
262
|
if (!rafPending) {
|
|
245
263
|
rafPending = true;
|
|
246
264
|
requestAnimationFrame(() => {
|
|
@@ -254,24 +272,24 @@ function enableHoverBreath(opts) {
|
|
|
254
272
|
processMouseMove(ev);
|
|
255
273
|
}
|
|
256
274
|
/**
|
|
257
|
-
*
|
|
275
|
+
* Actual mousemove logic
|
|
258
276
|
*/
|
|
259
277
|
function processMouseMove(ev) {
|
|
260
278
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
261
279
|
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
|
|
262
280
|
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
|
|
263
281
|
raycaster.setFromCamera(mouse, camera);
|
|
264
|
-
//
|
|
282
|
+
// Deep detect all children of the scene (true)
|
|
265
283
|
const intersects = raycaster.intersectObjects(scene.children, true);
|
|
266
284
|
if (intersects.length > 0) {
|
|
267
285
|
const obj = intersects[0].object;
|
|
268
|
-
//
|
|
286
|
+
// Determine if it is allowed to be highlighted
|
|
269
287
|
const allowed = highlightSet === null ? true : highlightSet.has(obj.name);
|
|
270
288
|
if (allowed) {
|
|
271
289
|
if (hovered !== obj) {
|
|
272
290
|
hovered = obj;
|
|
273
291
|
outlinePass.selectedObjects = [obj];
|
|
274
|
-
//
|
|
292
|
+
// Start animation (if not running)
|
|
275
293
|
if (animationId === null) {
|
|
276
294
|
animate();
|
|
277
295
|
}
|
|
@@ -281,7 +299,7 @@ function enableHoverBreath(opts) {
|
|
|
281
299
|
if (hovered !== null) {
|
|
282
300
|
hovered = null;
|
|
283
301
|
outlinePass.selectedObjects = [];
|
|
284
|
-
//
|
|
302
|
+
// Stop animation
|
|
285
303
|
if (animationId !== null) {
|
|
286
304
|
cancelAnimationFrame(animationId);
|
|
287
305
|
animationId = null;
|
|
@@ -293,7 +311,7 @@ function enableHoverBreath(opts) {
|
|
|
293
311
|
if (hovered !== null) {
|
|
294
312
|
hovered = null;
|
|
295
313
|
outlinePass.selectedObjects = [];
|
|
296
|
-
//
|
|
314
|
+
// Stop animation
|
|
297
315
|
if (animationId !== null) {
|
|
298
316
|
cancelAnimationFrame(animationId);
|
|
299
317
|
animationId = null;
|
|
@@ -302,10 +320,10 @@ function enableHoverBreath(opts) {
|
|
|
302
320
|
}
|
|
303
321
|
}
|
|
304
322
|
/**
|
|
305
|
-
*
|
|
323
|
+
* Animation loop - only runs when there is a hovered object
|
|
306
324
|
*/
|
|
307
325
|
function animate() {
|
|
308
|
-
//
|
|
326
|
+
// If no hovered object, stop animation
|
|
309
327
|
if (!hovered) {
|
|
310
328
|
animationId = null;
|
|
311
329
|
return;
|
|
@@ -315,11 +333,11 @@ function enableHoverBreath(opts) {
|
|
|
315
333
|
const strength = minStrength + ((Math.sin(time) + 1) / 2) * (maxStrength - minStrength);
|
|
316
334
|
outlinePass.edgeStrength = strength;
|
|
317
335
|
}
|
|
318
|
-
//
|
|
319
|
-
//
|
|
336
|
+
// Start (called only once)
|
|
337
|
+
// Use passive to improve scrolling performance
|
|
320
338
|
renderer.domElement.addEventListener('mousemove', onMouseMove, { passive: true });
|
|
321
|
-
//
|
|
322
|
-
// refresh:
|
|
339
|
+
// Note: Do not start animate here, wait until there is a hover object
|
|
340
|
+
// refresh: Forcibly clean up selectedObjects if needed
|
|
323
341
|
function refreshSelection() {
|
|
324
342
|
if (hovered && highlightSet && !highlightSet.has(hovered.name)) {
|
|
325
343
|
hovered = null;
|
|
@@ -340,7 +358,7 @@ function enableHoverBreath(opts) {
|
|
|
340
358
|
animationId = null;
|
|
341
359
|
}
|
|
342
360
|
outlinePass.selectedObjects = [];
|
|
343
|
-
//
|
|
361
|
+
// Clear references
|
|
344
362
|
hovered = null;
|
|
345
363
|
highlightSet = null;
|
|
346
364
|
}
|
|
@@ -353,23 +371,33 @@ function enableHoverBreath(opts) {
|
|
|
353
371
|
}
|
|
354
372
|
|
|
355
373
|
/**
|
|
356
|
-
*
|
|
374
|
+
* @file postProcessing.ts
|
|
375
|
+
* @description
|
|
376
|
+
* Manages the post-processing chain, specifically for Outline effects and Gamma correction.
|
|
357
377
|
*
|
|
358
|
-
*
|
|
359
|
-
* -
|
|
360
|
-
* -
|
|
361
|
-
* -
|
|
378
|
+
* @best-practice
|
|
379
|
+
* - call `initPostProcessing` after creating your renderer and scene.
|
|
380
|
+
* - Use the returned `composer` in your render loop instead of `renderer.render`.
|
|
381
|
+
* - Handles resizing automatically via the `resize` method.
|
|
382
|
+
*/
|
|
383
|
+
/**
|
|
384
|
+
* Initialize outline-related information (contains OutlinePass)
|
|
385
|
+
*
|
|
386
|
+
* Capabilities:
|
|
387
|
+
* - Supports automatic update on window resize
|
|
388
|
+
* - Configurable resolution scale for performance improvement
|
|
389
|
+
* - Comprehensive resource disposal management
|
|
362
390
|
*
|
|
363
391
|
* @param renderer THREE.WebGLRenderer
|
|
364
392
|
* @param scene THREE.Scene
|
|
365
393
|
* @param camera THREE.Camera
|
|
366
|
-
* @param options PostProcessingOptions -
|
|
367
|
-
* @returns PostProcessingManager -
|
|
394
|
+
* @param options PostProcessingOptions - Optional configuration
|
|
395
|
+
* @returns PostProcessingManager - Management interface containing composer/outlinePass/resize/dispose
|
|
368
396
|
*/
|
|
369
397
|
function initPostProcessing(renderer, scene, camera, options = {}) {
|
|
370
|
-
//
|
|
398
|
+
// Default configuration
|
|
371
399
|
const { edgeStrength = 4, edgeGlow = 1, edgeThickness = 2, visibleEdgeColor = '#ffee00', hiddenEdgeColor = '#000000', resolutionScale = 1.0 } = options;
|
|
372
|
-
//
|
|
400
|
+
// Get renderer actual size
|
|
373
401
|
const getSize = () => {
|
|
374
402
|
const width = renderer.domElement.clientWidth;
|
|
375
403
|
const height = renderer.domElement.clientHeight;
|
|
@@ -379,12 +407,12 @@ function initPostProcessing(renderer, scene, camera, options = {}) {
|
|
|
379
407
|
};
|
|
380
408
|
};
|
|
381
409
|
const size = getSize();
|
|
382
|
-
//
|
|
410
|
+
// Create EffectComposer
|
|
383
411
|
const composer = new EffectComposer(renderer);
|
|
384
|
-
//
|
|
412
|
+
// Basic RenderPass
|
|
385
413
|
const renderPass = new RenderPass(scene, camera);
|
|
386
414
|
composer.addPass(renderPass);
|
|
387
|
-
// OutlinePass
|
|
415
|
+
// OutlinePass for model outlining
|
|
388
416
|
const outlinePass = new OutlinePass(new THREE.Vector2(size.width, size.height), scene, camera);
|
|
389
417
|
outlinePass.edgeStrength = edgeStrength;
|
|
390
418
|
outlinePass.edgeGlow = edgeGlow;
|
|
@@ -392,34 +420,34 @@ function initPostProcessing(renderer, scene, camera, options = {}) {
|
|
|
392
420
|
outlinePass.visibleEdgeColor.set(visibleEdgeColor);
|
|
393
421
|
outlinePass.hiddenEdgeColor.set(hiddenEdgeColor);
|
|
394
422
|
composer.addPass(outlinePass);
|
|
395
|
-
// Gamma
|
|
423
|
+
// Gamma correction
|
|
396
424
|
const gammaPass = new ShaderPass(GammaCorrectionShader);
|
|
397
425
|
composer.addPass(gammaPass);
|
|
398
426
|
/**
|
|
399
|
-
* resize
|
|
400
|
-
* @param width
|
|
401
|
-
* @param height
|
|
427
|
+
* Handle resize
|
|
428
|
+
* @param width Optional width, uses renderer.domElement actual width if not provided
|
|
429
|
+
* @param height Optional height, uses renderer.domElement actual height if not provided
|
|
402
430
|
*/
|
|
403
431
|
const resize = (width, height) => {
|
|
404
432
|
const actualSize = width !== undefined && height !== undefined
|
|
405
433
|
? { width: Math.floor(width * resolutionScale), height: Math.floor(height * resolutionScale) }
|
|
406
434
|
: getSize();
|
|
407
|
-
//
|
|
435
|
+
// Update composer size
|
|
408
436
|
composer.setSize(actualSize.width, actualSize.height);
|
|
409
|
-
//
|
|
437
|
+
// Update outlinePass resolution
|
|
410
438
|
outlinePass.resolution.set(actualSize.width, actualSize.height);
|
|
411
439
|
};
|
|
412
440
|
/**
|
|
413
|
-
*
|
|
441
|
+
* Dispose resources
|
|
414
442
|
*/
|
|
415
443
|
const dispose = () => {
|
|
416
|
-
//
|
|
444
|
+
// Dipose all passes
|
|
417
445
|
composer.passes.forEach(pass => {
|
|
418
446
|
if (pass.dispose) {
|
|
419
447
|
pass.dispose();
|
|
420
448
|
}
|
|
421
449
|
});
|
|
422
|
-
//
|
|
450
|
+
// Clear passes array
|
|
423
451
|
composer.passes.length = 0;
|
|
424
452
|
};
|
|
425
453
|
return {
|
|
@@ -431,28 +459,99 @@ function initPostProcessing(renderer, scene, camera, options = {}) {
|
|
|
431
459
|
}
|
|
432
460
|
|
|
433
461
|
/**
|
|
434
|
-
*
|
|
462
|
+
* ResourceManager
|
|
463
|
+
* Handles tracking and disposal of Three.js objects to prevent memory leaks.
|
|
464
|
+
*/
|
|
465
|
+
class ResourceManager {
|
|
466
|
+
constructor() {
|
|
467
|
+
this.geometries = new Set();
|
|
468
|
+
this.materials = new Set();
|
|
469
|
+
this.textures = new Set();
|
|
470
|
+
this.objects = new Set();
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Track an object and its resources recursively
|
|
474
|
+
*/
|
|
475
|
+
track(object) {
|
|
476
|
+
this.objects.add(object);
|
|
477
|
+
object.traverse((child) => {
|
|
478
|
+
if (child.isMesh) {
|
|
479
|
+
const mesh = child;
|
|
480
|
+
if (mesh.geometry)
|
|
481
|
+
this.geometries.add(mesh.geometry);
|
|
482
|
+
if (mesh.material) {
|
|
483
|
+
if (Array.isArray(mesh.material)) {
|
|
484
|
+
mesh.material.forEach(m => this.trackMaterial(m));
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
this.trackMaterial(mesh.material);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
return object;
|
|
493
|
+
}
|
|
494
|
+
trackMaterial(material) {
|
|
495
|
+
this.materials.add(material);
|
|
496
|
+
// Track textures in material
|
|
497
|
+
for (const value of Object.values(material)) {
|
|
498
|
+
if (value instanceof THREE.Texture) {
|
|
499
|
+
this.textures.add(value);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Dispose all tracked resources
|
|
505
|
+
*/
|
|
506
|
+
dispose() {
|
|
507
|
+
this.geometries.forEach(g => g.dispose());
|
|
508
|
+
this.materials.forEach(m => m.dispose());
|
|
509
|
+
this.textures.forEach(t => t.dispose());
|
|
510
|
+
this.objects.forEach(obj => {
|
|
511
|
+
if (obj.parent) {
|
|
512
|
+
obj.parent.remove(obj);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
this.geometries.clear();
|
|
516
|
+
this.materials.clear();
|
|
517
|
+
this.textures.clear();
|
|
518
|
+
this.objects.clear();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* @file clickHandler.ts
|
|
524
|
+
* @description
|
|
525
|
+
* Tool for handling model clicks and highlighting (OutlinePass version).
|
|
435
526
|
*
|
|
436
|
-
*
|
|
437
|
-
* -
|
|
438
|
-
* -
|
|
439
|
-
* -
|
|
440
|
-
|
|
527
|
+
* @best-practice
|
|
528
|
+
* - Use `createModelClickHandler` to setup interaction.
|
|
529
|
+
* - Handles debouncing and click threshold automatically.
|
|
530
|
+
* - Cleanup using the returned dispose function.
|
|
531
|
+
*/
|
|
532
|
+
/**
|
|
533
|
+
* Create Model Click Highlight Tool (OutlinePass Version) - Optimized
|
|
534
|
+
*
|
|
535
|
+
* Features:
|
|
536
|
+
* - Uses AbortController to unify event lifecycle management
|
|
537
|
+
* - Supports debounce to avoid frequent triggering
|
|
538
|
+
* - Customizable Raycaster parameters
|
|
539
|
+
* - Dynamically adjusts outline thickness based on camera distance
|
|
441
540
|
*
|
|
442
|
-
* @param camera
|
|
443
|
-
* @param scene
|
|
444
|
-
* @param renderer
|
|
445
|
-
* @param outlinePass
|
|
446
|
-
* @param onClick
|
|
447
|
-
* @param options
|
|
448
|
-
* @returns
|
|
541
|
+
* @param camera Camera
|
|
542
|
+
* @param scene Scene
|
|
543
|
+
* @param renderer Renderer
|
|
544
|
+
* @param outlinePass Initialized OutlinePass
|
|
545
|
+
* @param onClick Click callback
|
|
546
|
+
* @param options Optional configuration
|
|
547
|
+
* @returns Dispose function, used to clean up events and resources
|
|
449
548
|
*/
|
|
450
549
|
function createModelClickHandler(camera, scene, renderer, outlinePass, onClick, options = {}) {
|
|
451
|
-
//
|
|
550
|
+
// Configuration
|
|
452
551
|
const { clickThreshold = 3, debounceDelay = 0, raycasterParams = {}, enableDynamicThickness = true, minThickness = 1, maxThickness = 10 } = options;
|
|
453
552
|
const raycaster = new THREE.Raycaster();
|
|
454
553
|
const mouse = new THREE.Vector2();
|
|
455
|
-
//
|
|
554
|
+
// Apply Raycaster custom parameters
|
|
456
555
|
if (raycasterParams.near !== undefined)
|
|
457
556
|
raycaster.near = raycasterParams.near;
|
|
458
557
|
if (raycasterParams.far !== undefined)
|
|
@@ -469,25 +568,25 @@ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick,
|
|
|
469
568
|
let startY = 0;
|
|
470
569
|
let selectedObject = null;
|
|
471
570
|
let debounceTimer = null;
|
|
472
|
-
//
|
|
571
|
+
// Use AbortController to manage events uniformly
|
|
473
572
|
const abortController = new AbortController();
|
|
474
573
|
const signal = abortController.signal;
|
|
475
|
-
/**
|
|
574
|
+
/** Restore object highlight (Clear OutlinePass.selectedObjects) */
|
|
476
575
|
function restoreObject() {
|
|
477
576
|
outlinePass.selectedObjects = [];
|
|
478
577
|
}
|
|
479
|
-
/**
|
|
578
|
+
/** Record mouse down position */
|
|
480
579
|
function handleMouseDown(event) {
|
|
481
580
|
startX = event.clientX;
|
|
482
581
|
startY = event.clientY;
|
|
483
582
|
}
|
|
484
|
-
/**
|
|
583
|
+
/** Mouse up determines click or drag (with debounce) */
|
|
485
584
|
function handleMouseUp(event) {
|
|
486
585
|
const dx = Math.abs(event.clientX - startX);
|
|
487
586
|
const dy = Math.abs(event.clientY - startY);
|
|
488
587
|
if (dx > clickThreshold || dy > clickThreshold)
|
|
489
|
-
return; //
|
|
490
|
-
//
|
|
588
|
+
return; // Drag does not trigger click
|
|
589
|
+
// Debounce processing
|
|
491
590
|
if (debounceDelay > 0) {
|
|
492
591
|
if (debounceTimer !== null) {
|
|
493
592
|
clearTimeout(debounceTimer);
|
|
@@ -501,7 +600,7 @@ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick,
|
|
|
501
600
|
processClick(event);
|
|
502
601
|
}
|
|
503
602
|
}
|
|
504
|
-
/**
|
|
603
|
+
/** Actual click processing logic */
|
|
505
604
|
function processClick(event) {
|
|
506
605
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
507
606
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
@@ -510,59 +609,67 @@ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick,
|
|
|
510
609
|
const intersects = raycaster.intersectObjects(scene.children, true);
|
|
511
610
|
if (intersects.length > 0) {
|
|
512
611
|
let object = intersects[0].object;
|
|
513
|
-
//
|
|
612
|
+
// Click different model, clear previous highlight first
|
|
514
613
|
if (selectedObject && selectedObject !== object)
|
|
515
614
|
restoreObject();
|
|
516
615
|
selectedObject = object;
|
|
517
|
-
// highlightObject(selectedObject); //
|
|
616
|
+
// highlightObject(selectedObject); // Optional: whether to auto highlight
|
|
518
617
|
onClick(selectedObject, {
|
|
519
|
-
name: selectedObject.name || '
|
|
618
|
+
name: selectedObject.name || 'Unnamed Model',
|
|
520
619
|
position: selectedObject.getWorldPosition(new THREE.Vector3()),
|
|
521
620
|
uuid: selectedObject.uuid
|
|
522
621
|
});
|
|
523
622
|
}
|
|
524
623
|
else {
|
|
525
|
-
//
|
|
624
|
+
// Click blank -> Clear highlight
|
|
526
625
|
if (selectedObject)
|
|
527
626
|
restoreObject();
|
|
528
627
|
selectedObject = null;
|
|
529
628
|
onClick(null);
|
|
530
629
|
}
|
|
531
630
|
}
|
|
532
|
-
//
|
|
631
|
+
// Register events using signal from AbortController
|
|
533
632
|
renderer.domElement.addEventListener('mousedown', handleMouseDown, { signal });
|
|
534
633
|
renderer.domElement.addEventListener('mouseup', handleMouseUp, { signal });
|
|
535
|
-
/**
|
|
634
|
+
/** Dispose function: Unbind events and clear highlight */
|
|
536
635
|
return () => {
|
|
537
|
-
//
|
|
636
|
+
// Clear debounce timer
|
|
538
637
|
if (debounceTimer !== null) {
|
|
539
638
|
clearTimeout(debounceTimer);
|
|
540
639
|
debounceTimer = null;
|
|
541
640
|
}
|
|
542
|
-
//
|
|
641
|
+
// Unbind all events at once
|
|
543
642
|
abortController.abort();
|
|
544
|
-
//
|
|
643
|
+
// Clear highlight
|
|
545
644
|
restoreObject();
|
|
546
|
-
//
|
|
645
|
+
// Clear reference
|
|
547
646
|
selectedObject = null;
|
|
548
647
|
};
|
|
549
648
|
}
|
|
550
649
|
|
|
551
|
-
// src/utils/ArrowGuide.ts
|
|
552
650
|
/**
|
|
553
|
-
*
|
|
554
|
-
*
|
|
651
|
+
* @file arrowGuide.ts
|
|
652
|
+
* @description
|
|
653
|
+
* Arrow guide effect tool, supports highlighting models and fading other objects.
|
|
654
|
+
*
|
|
655
|
+
* @best-practice
|
|
656
|
+
* - Use `highlight` to focus on specific models.
|
|
657
|
+
* - Automatically manages materials and memory using WeakMap.
|
|
658
|
+
* - Call `dispose` when component unmounts.
|
|
659
|
+
*/
|
|
660
|
+
/**
|
|
661
|
+
* ArrowGuide - Optimized Version
|
|
662
|
+
* Arrow guide effect tool, supports highlighting models and fading other objects.
|
|
555
663
|
*
|
|
556
|
-
*
|
|
557
|
-
* -
|
|
558
|
-
* -
|
|
559
|
-
* -
|
|
560
|
-
* -
|
|
561
|
-
* -
|
|
664
|
+
* Features:
|
|
665
|
+
* - Uses WeakMap for automatic material recycling, preventing memory leaks
|
|
666
|
+
* - Uses AbortController to manage event lifecycle
|
|
667
|
+
* - Adds material reuse mechanism to reuse materials
|
|
668
|
+
* - Improved dispose logic ensuring complete resource release
|
|
669
|
+
* - Adds error handling and boundary checks
|
|
562
670
|
*/
|
|
563
671
|
class ArrowGuide {
|
|
564
672
|
constructor(renderer, camera, scene, options) {
|
|
565
|
-
var _a, _b, _c;
|
|
566
673
|
this.renderer = renderer;
|
|
567
674
|
this.camera = camera;
|
|
568
675
|
this.scene = scene;
|
|
@@ -573,45 +680,45 @@ class ArrowGuide {
|
|
|
573
680
|
this.clickThreshold = 10;
|
|
574
681
|
this.raycaster = new THREE.Raycaster();
|
|
575
682
|
this.mouse = new THREE.Vector2();
|
|
576
|
-
//
|
|
683
|
+
// Use WeakMap for automatic material recycling (GC friendly)
|
|
577
684
|
this.originalMaterials = new WeakMap();
|
|
578
685
|
this.fadedMaterials = new WeakMap();
|
|
579
|
-
//
|
|
686
|
+
// AbortController for event management
|
|
580
687
|
this.abortController = null;
|
|
581
|
-
//
|
|
688
|
+
// Config: Non-highlight opacity and brightness
|
|
582
689
|
this.fadeOpacity = 0.5;
|
|
583
690
|
this.fadeBrightness = 0.1;
|
|
584
|
-
this.clickThreshold =
|
|
585
|
-
this.ignoreRaycastNames = new Set(
|
|
586
|
-
this.fadeOpacity =
|
|
587
|
-
this.fadeBrightness =
|
|
691
|
+
this.clickThreshold = options?.clickThreshold ?? 10;
|
|
692
|
+
this.ignoreRaycastNames = new Set(options?.ignoreRaycastNames || []);
|
|
693
|
+
this.fadeOpacity = options?.fadeOpacity ?? 0.5;
|
|
694
|
+
this.fadeBrightness = options?.fadeBrightness ?? 0.1;
|
|
588
695
|
this.abortController = new AbortController();
|
|
589
696
|
this.initEvents();
|
|
590
697
|
}
|
|
591
|
-
//
|
|
698
|
+
// Tool: Cache original material (first time only)
|
|
592
699
|
cacheOriginalMaterial(mesh) {
|
|
593
700
|
if (!this.originalMaterials.has(mesh)) {
|
|
594
701
|
this.originalMaterials.set(mesh, mesh.material);
|
|
595
702
|
}
|
|
596
703
|
}
|
|
597
|
-
//
|
|
704
|
+
// Tool: Clone a "translucent version" for a material, preserving all maps and parameters
|
|
598
705
|
makeFadedClone(mat) {
|
|
599
706
|
const clone = mat.clone();
|
|
600
707
|
const c = clone;
|
|
601
|
-
//
|
|
708
|
+
// Only modify transparency parameters, do not modify detail maps like map / normalMap / roughnessMap
|
|
602
709
|
c.transparent = true;
|
|
603
710
|
if (typeof c.opacity === 'number')
|
|
604
711
|
c.opacity = this.fadeOpacity;
|
|
605
712
|
if (c.color && c.color.isColor) {
|
|
606
|
-
c.color.multiplyScalar(this.fadeBrightness); //
|
|
713
|
+
c.color.multiplyScalar(this.fadeBrightness); // Darken color overall
|
|
607
714
|
}
|
|
608
|
-
//
|
|
715
|
+
// Common strategy for fluid display behind transparent objects: do not write depth, only test depth
|
|
609
716
|
clone.depthWrite = false;
|
|
610
717
|
clone.depthTest = true;
|
|
611
718
|
clone.needsUpdate = true;
|
|
612
719
|
return clone;
|
|
613
720
|
}
|
|
614
|
-
//
|
|
721
|
+
// Tool: Batch clone "translucent version" for mesh.material (could be array)
|
|
615
722
|
createFadedMaterialFrom(mesh) {
|
|
616
723
|
const orig = mesh.material;
|
|
617
724
|
if (Array.isArray(orig)) {
|
|
@@ -620,7 +727,7 @@ class ArrowGuide {
|
|
|
620
727
|
return this.makeFadedClone(orig);
|
|
621
728
|
}
|
|
622
729
|
/**
|
|
623
|
-
*
|
|
730
|
+
* Set Arrow Mesh
|
|
624
731
|
*/
|
|
625
732
|
setArrowMesh(mesh) {
|
|
626
733
|
this.lxMesh = mesh;
|
|
@@ -636,15 +743,15 @@ class ArrowGuide {
|
|
|
636
743
|
mesh.visible = false;
|
|
637
744
|
}
|
|
638
745
|
catch (error) {
|
|
639
|
-
console.error('ArrowGuide:
|
|
746
|
+
console.error('ArrowGuide: Failed to set arrow material', error);
|
|
640
747
|
}
|
|
641
748
|
}
|
|
642
749
|
/**
|
|
643
|
-
*
|
|
750
|
+
* Highlight specified models
|
|
644
751
|
*/
|
|
645
752
|
highlight(models) {
|
|
646
753
|
if (!models || models.length === 0) {
|
|
647
|
-
console.warn('ArrowGuide:
|
|
754
|
+
console.warn('ArrowGuide: Highlight model list is empty');
|
|
648
755
|
return;
|
|
649
756
|
}
|
|
650
757
|
this.modelBrightArr = models;
|
|
@@ -653,9 +760,9 @@ class ArrowGuide {
|
|
|
653
760
|
this.lxMesh.visible = true;
|
|
654
761
|
this.applyHighlight();
|
|
655
762
|
}
|
|
656
|
-
//
|
|
763
|
+
// Apply highlight effect: Non-highlighted models preserve details -> use "cloned translucent material"
|
|
657
764
|
applyHighlight() {
|
|
658
|
-
//
|
|
765
|
+
// Use Set to improve lookup performance
|
|
659
766
|
const keepMeshes = new Set();
|
|
660
767
|
this.modelBrightArr.forEach(obj => {
|
|
661
768
|
obj.traverse(child => {
|
|
@@ -667,21 +774,21 @@ class ArrowGuide {
|
|
|
667
774
|
this.scene.traverse(obj => {
|
|
668
775
|
if (obj.isMesh) {
|
|
669
776
|
const mesh = obj;
|
|
670
|
-
//
|
|
777
|
+
// Cache original material (for restoration)
|
|
671
778
|
this.cacheOriginalMaterial(mesh);
|
|
672
779
|
if (!keepMeshes.has(mesh)) {
|
|
673
|
-
//
|
|
780
|
+
// Non-highlighted: if no "translucent clone material" generated yet, create one
|
|
674
781
|
if (!this.fadedMaterials.has(mesh)) {
|
|
675
782
|
const faded = this.createFadedMaterialFrom(mesh);
|
|
676
783
|
this.fadedMaterials.set(mesh, faded);
|
|
677
784
|
}
|
|
678
|
-
//
|
|
785
|
+
// Replace with clone material (preserve all maps/normals details)
|
|
679
786
|
const fadedMat = this.fadedMaterials.get(mesh);
|
|
680
787
|
if (fadedMat)
|
|
681
788
|
mesh.material = fadedMat;
|
|
682
789
|
}
|
|
683
790
|
else {
|
|
684
|
-
//
|
|
791
|
+
// Highlighted object: ensure return to original material (avoid leftover from previous highlight)
|
|
685
792
|
const orig = this.originalMaterials.get(mesh);
|
|
686
793
|
if (orig && mesh.material !== orig) {
|
|
687
794
|
mesh.material = orig;
|
|
@@ -692,16 +799,16 @@ class ArrowGuide {
|
|
|
692
799
|
});
|
|
693
800
|
}
|
|
694
801
|
catch (error) {
|
|
695
|
-
console.error('ArrowGuide:
|
|
802
|
+
console.error('ArrowGuide: Failed to apply highlight', error);
|
|
696
803
|
}
|
|
697
804
|
}
|
|
698
|
-
//
|
|
805
|
+
// Restore to original material & dispose clone material
|
|
699
806
|
restore() {
|
|
700
807
|
this.flowActive = false;
|
|
701
808
|
if (this.lxMesh)
|
|
702
809
|
this.lxMesh.visible = false;
|
|
703
810
|
try {
|
|
704
|
-
//
|
|
811
|
+
// Collect all materials to dispose
|
|
705
812
|
const materialsToDispose = [];
|
|
706
813
|
this.scene.traverse(obj => {
|
|
707
814
|
if (obj.isMesh) {
|
|
@@ -711,7 +818,7 @@ class ArrowGuide {
|
|
|
711
818
|
mesh.material = orig;
|
|
712
819
|
mesh.material.needsUpdate = true;
|
|
713
820
|
}
|
|
714
|
-
//
|
|
821
|
+
// Collect faded materials to dispose
|
|
715
822
|
const faded = this.fadedMaterials.get(mesh);
|
|
716
823
|
if (faded) {
|
|
717
824
|
if (Array.isArray(faded)) {
|
|
@@ -723,24 +830,24 @@ class ArrowGuide {
|
|
|
723
830
|
}
|
|
724
831
|
}
|
|
725
832
|
});
|
|
726
|
-
//
|
|
833
|
+
// Batch dispose materials (do not touch texture resources)
|
|
727
834
|
materialsToDispose.forEach(mat => {
|
|
728
835
|
try {
|
|
729
836
|
mat.dispose();
|
|
730
837
|
}
|
|
731
838
|
catch (error) {
|
|
732
|
-
console.error('ArrowGuide:
|
|
839
|
+
console.error('ArrowGuide: Failed to dispose material', error);
|
|
733
840
|
}
|
|
734
841
|
});
|
|
735
|
-
//
|
|
842
|
+
// Create new WeakMap (equivalent to clearing)
|
|
736
843
|
this.fadedMaterials = new WeakMap();
|
|
737
844
|
}
|
|
738
845
|
catch (error) {
|
|
739
|
-
console.error('ArrowGuide:
|
|
846
|
+
console.error('ArrowGuide: Failed to restore material', error);
|
|
740
847
|
}
|
|
741
848
|
}
|
|
742
849
|
/**
|
|
743
|
-
*
|
|
850
|
+
* Animation update (called every frame)
|
|
744
851
|
*/
|
|
745
852
|
animate() {
|
|
746
853
|
if (!this.flowActive || !this.lxMesh)
|
|
@@ -754,16 +861,16 @@ class ArrowGuide {
|
|
|
754
861
|
}
|
|
755
862
|
}
|
|
756
863
|
catch (error) {
|
|
757
|
-
console.error('ArrowGuide:
|
|
864
|
+
console.error('ArrowGuide: Animation update failed', error);
|
|
758
865
|
}
|
|
759
866
|
}
|
|
760
867
|
/**
|
|
761
|
-
*
|
|
868
|
+
* Initialize event listeners
|
|
762
869
|
*/
|
|
763
870
|
initEvents() {
|
|
764
871
|
const dom = this.renderer.domElement;
|
|
765
872
|
const signal = this.abortController.signal;
|
|
766
|
-
//
|
|
873
|
+
// Use AbortController signal to automatically manage event lifecycle
|
|
767
874
|
dom.addEventListener('pointerdown', (e) => {
|
|
768
875
|
this.pointerDownPos.set(e.clientX, e.clientY);
|
|
769
876
|
}, { signal });
|
|
@@ -771,7 +878,7 @@ class ArrowGuide {
|
|
|
771
878
|
const dx = Math.abs(e.clientX - this.pointerDownPos.x);
|
|
772
879
|
const dy = Math.abs(e.clientY - this.pointerDownPos.y);
|
|
773
880
|
if (dx > this.clickThreshold || dy > this.clickThreshold)
|
|
774
|
-
return; //
|
|
881
|
+
return; // Dragging
|
|
775
882
|
const rect = dom.getBoundingClientRect();
|
|
776
883
|
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
777
884
|
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
@@ -785,21 +892,21 @@ class ArrowGuide {
|
|
|
785
892
|
return true;
|
|
786
893
|
});
|
|
787
894
|
if (filtered.length === 0)
|
|
788
|
-
this.restore(); //
|
|
895
|
+
this.restore(); // Click blank space to restore
|
|
789
896
|
}, { signal });
|
|
790
897
|
}
|
|
791
898
|
/**
|
|
792
|
-
*
|
|
899
|
+
* Dispose all resources
|
|
793
900
|
*/
|
|
794
901
|
dispose() {
|
|
795
|
-
//
|
|
902
|
+
// Restore materials first
|
|
796
903
|
this.restore();
|
|
797
|
-
//
|
|
904
|
+
// Unbind all events at once using AbortController
|
|
798
905
|
if (this.abortController) {
|
|
799
906
|
this.abortController.abort();
|
|
800
907
|
this.abortController = null;
|
|
801
908
|
}
|
|
802
|
-
//
|
|
909
|
+
// Clear references
|
|
803
910
|
this.modelBrightArr = [];
|
|
804
911
|
this.lxMesh = null;
|
|
805
912
|
this.fadedMaterials = new WeakMap();
|
|
@@ -808,50 +915,59 @@ class ArrowGuide {
|
|
|
808
915
|
}
|
|
809
916
|
}
|
|
810
917
|
|
|
811
|
-
// utils/LiquidFillerGroup.ts
|
|
812
918
|
/**
|
|
813
|
-
*
|
|
814
|
-
*
|
|
919
|
+
* @file liquidFiller.ts
|
|
920
|
+
* @description
|
|
921
|
+
* Liquid filling effect for single or multiple models using local clipping planes.
|
|
815
922
|
*
|
|
816
|
-
*
|
|
817
|
-
* -
|
|
818
|
-
* -
|
|
819
|
-
* -
|
|
820
|
-
|
|
821
|
-
|
|
923
|
+
* @best-practice
|
|
924
|
+
* - Use `fillTo` to animate liquid level.
|
|
925
|
+
* - Supports multiple independent liquid levels.
|
|
926
|
+
* - Call `dispose` to clean up resources and event listeners.
|
|
927
|
+
*/
|
|
928
|
+
/**
|
|
929
|
+
* LiquidFillerGroup - Optimized
|
|
930
|
+
* Supports single or multi-model liquid level animation with independent color control.
|
|
931
|
+
*
|
|
932
|
+
* Features:
|
|
933
|
+
* - Uses renderer.domElement instead of window events
|
|
934
|
+
* - Uses AbortController to manage event lifecycle
|
|
935
|
+
* - Adds error handling and boundary checks
|
|
936
|
+
* - Optimized animation management to prevent memory leaks
|
|
937
|
+
* - Comprehensive resource disposal logic
|
|
822
938
|
*/
|
|
823
939
|
class LiquidFillerGroup {
|
|
824
940
|
/**
|
|
825
|
-
*
|
|
826
|
-
* @param models
|
|
827
|
-
* @param scene
|
|
828
|
-
* @param camera
|
|
829
|
-
* @param renderer
|
|
830
|
-
* @param defaultOptions
|
|
831
|
-
* @param clickThreshold
|
|
941
|
+
* Constructor
|
|
942
|
+
* @param models Single or multiple THREE.Object3D
|
|
943
|
+
* @param scene Scene
|
|
944
|
+
* @param camera Camera
|
|
945
|
+
* @param renderer Renderer
|
|
946
|
+
* @param defaultOptions Default liquid options
|
|
947
|
+
* @param clickThreshold Click threshold in pixels
|
|
832
948
|
*/
|
|
833
949
|
constructor(models, scene, camera, renderer, defaultOptions, clickThreshold = 10) {
|
|
834
950
|
this.items = [];
|
|
835
951
|
this.raycaster = new THREE.Raycaster();
|
|
836
952
|
this.pointerDownPos = new THREE.Vector2();
|
|
837
953
|
this.clickThreshold = 10;
|
|
838
|
-
this.abortController = null; //
|
|
839
|
-
/** pointerdown
|
|
954
|
+
this.abortController = null; // Event manager
|
|
955
|
+
/** pointerdown record position */
|
|
840
956
|
this.handlePointerDown = (event) => {
|
|
841
957
|
this.pointerDownPos.set(event.clientX, event.clientY);
|
|
842
958
|
};
|
|
843
|
-
/** pointerup
|
|
959
|
+
/** pointerup check click blank, restore original material */
|
|
844
960
|
this.handlePointerUp = (event) => {
|
|
845
961
|
const dx = event.clientX - this.pointerDownPos.x;
|
|
846
962
|
const dy = event.clientY - this.pointerDownPos.y;
|
|
847
963
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
848
964
|
if (distance > this.clickThreshold)
|
|
849
|
-
return; //
|
|
850
|
-
//
|
|
965
|
+
return; // Do not trigger on drag
|
|
966
|
+
// Use renderer.domElement actual size
|
|
851
967
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
852
968
|
const pointerNDC = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
|
|
853
969
|
this.raycaster.setFromCamera(pointerNDC, this.camera);
|
|
854
|
-
//
|
|
970
|
+
// Click blank -> Restore all models
|
|
855
971
|
const intersectsAny = this.items.some(item => this.raycaster.intersectObject(item.model, true).length > 0);
|
|
856
972
|
if (!intersectsAny) {
|
|
857
973
|
this.restoreAll();
|
|
@@ -861,18 +977,17 @@ class LiquidFillerGroup {
|
|
|
861
977
|
this.camera = camera;
|
|
862
978
|
this.renderer = renderer;
|
|
863
979
|
this.clickThreshold = clickThreshold;
|
|
864
|
-
//
|
|
980
|
+
// Create AbortController for event management
|
|
865
981
|
this.abortController = new AbortController();
|
|
866
982
|
const modelArray = Array.isArray(models) ? models : [models];
|
|
867
983
|
modelArray.forEach(model => {
|
|
868
|
-
var _a, _b, _c;
|
|
869
984
|
try {
|
|
870
985
|
const options = {
|
|
871
|
-
color:
|
|
872
|
-
opacity:
|
|
873
|
-
speed:
|
|
986
|
+
color: defaultOptions?.color ?? 0x00ff00,
|
|
987
|
+
opacity: defaultOptions?.opacity ?? 0.6,
|
|
988
|
+
speed: defaultOptions?.speed ?? 0.05,
|
|
874
989
|
};
|
|
875
|
-
//
|
|
990
|
+
// Save original materials
|
|
876
991
|
const originalMaterials = new Map();
|
|
877
992
|
model.traverse(obj => {
|
|
878
993
|
if (obj.isMesh) {
|
|
@@ -880,12 +995,12 @@ class LiquidFillerGroup {
|
|
|
880
995
|
originalMaterials.set(mesh, mesh.material);
|
|
881
996
|
}
|
|
882
997
|
});
|
|
883
|
-
//
|
|
998
|
+
// Boundary check: ensure there are materials to save
|
|
884
999
|
if (originalMaterials.size === 0) {
|
|
885
|
-
console.warn('LiquidFillerGroup:
|
|
1000
|
+
console.warn('LiquidFillerGroup: Model has no Mesh objects', model);
|
|
886
1001
|
return;
|
|
887
1002
|
}
|
|
888
|
-
//
|
|
1003
|
+
// Apply faded wireframe material
|
|
889
1004
|
model.traverse(obj => {
|
|
890
1005
|
if (obj.isMesh) {
|
|
891
1006
|
const mesh = obj;
|
|
@@ -897,7 +1012,7 @@ class LiquidFillerGroup {
|
|
|
897
1012
|
});
|
|
898
1013
|
}
|
|
899
1014
|
});
|
|
900
|
-
//
|
|
1015
|
+
// Create liquid Mesh
|
|
901
1016
|
const geometries = [];
|
|
902
1017
|
model.traverse(obj => {
|
|
903
1018
|
if (obj.isMesh) {
|
|
@@ -908,12 +1023,12 @@ class LiquidFillerGroup {
|
|
|
908
1023
|
}
|
|
909
1024
|
});
|
|
910
1025
|
if (geometries.length === 0) {
|
|
911
|
-
console.warn('LiquidFillerGroup:
|
|
1026
|
+
console.warn('LiquidFillerGroup: Model has no geometries', model);
|
|
912
1027
|
return;
|
|
913
1028
|
}
|
|
914
1029
|
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries, false);
|
|
915
1030
|
if (!mergedGeometry) {
|
|
916
|
-
console.error('LiquidFillerGroup:
|
|
1031
|
+
console.error('LiquidFillerGroup: Failed to merge geometries', model);
|
|
917
1032
|
return;
|
|
918
1033
|
}
|
|
919
1034
|
const material = new THREE.MeshPhongMaterial({
|
|
@@ -924,7 +1039,7 @@ class LiquidFillerGroup {
|
|
|
924
1039
|
});
|
|
925
1040
|
const liquidMesh = new THREE.Mesh(mergedGeometry, material);
|
|
926
1041
|
this.scene.add(liquidMesh);
|
|
927
|
-
//
|
|
1042
|
+
// Set clippingPlane
|
|
928
1043
|
const clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
|
929
1044
|
const mat = liquidMesh.material;
|
|
930
1045
|
mat.clippingPlanes = [clipPlane];
|
|
@@ -935,41 +1050,41 @@ class LiquidFillerGroup {
|
|
|
935
1050
|
clipPlane,
|
|
936
1051
|
originalMaterials,
|
|
937
1052
|
options,
|
|
938
|
-
animationId: null //
|
|
1053
|
+
animationId: null // Initialize animation ID
|
|
939
1054
|
});
|
|
940
1055
|
}
|
|
941
1056
|
catch (error) {
|
|
942
|
-
console.error('LiquidFillerGroup:
|
|
1057
|
+
console.error('LiquidFillerGroup: Failed to initialize model', model, error);
|
|
943
1058
|
}
|
|
944
1059
|
});
|
|
945
|
-
//
|
|
1060
|
+
// Use renderer.domElement instead of window, use AbortController signal
|
|
946
1061
|
const signal = this.abortController.signal;
|
|
947
1062
|
this.renderer.domElement.addEventListener('pointerdown', this.handlePointerDown, { signal });
|
|
948
1063
|
this.renderer.domElement.addEventListener('pointerup', this.handlePointerUp, { signal });
|
|
949
1064
|
}
|
|
950
1065
|
/**
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1066
|
+
* Set liquid level
|
|
1067
|
+
* @param models Single model or array of models
|
|
1068
|
+
* @param percent Liquid level percentage 0~1
|
|
1069
|
+
*/
|
|
955
1070
|
fillTo(models, percent) {
|
|
956
|
-
//
|
|
1071
|
+
// Boundary check
|
|
957
1072
|
if (percent < 0 || percent > 1) {
|
|
958
|
-
console.warn('LiquidFillerGroup: percent
|
|
1073
|
+
console.warn('LiquidFillerGroup: percent must be between 0 and 1', percent);
|
|
959
1074
|
percent = Math.max(0, Math.min(1, percent));
|
|
960
1075
|
}
|
|
961
1076
|
const modelArray = Array.isArray(models) ? models : [models];
|
|
962
1077
|
modelArray.forEach(model => {
|
|
963
1078
|
const item = this.items.find(i => i.model === model);
|
|
964
1079
|
if (!item) {
|
|
965
|
-
console.warn('LiquidFillerGroup:
|
|
1080
|
+
console.warn('LiquidFillerGroup: Model not found', model);
|
|
966
1081
|
return;
|
|
967
1082
|
}
|
|
968
1083
|
if (!item.liquidMesh) {
|
|
969
|
-
console.warn('LiquidFillerGroup: liquidMesh
|
|
1084
|
+
console.warn('LiquidFillerGroup: liquidMesh already disposed', model);
|
|
970
1085
|
return;
|
|
971
1086
|
}
|
|
972
|
-
//
|
|
1087
|
+
// Cancel previous animation
|
|
973
1088
|
if (item.animationId !== null) {
|
|
974
1089
|
cancelAnimationFrame(item.animationId);
|
|
975
1090
|
item.animationId = null;
|
|
@@ -997,14 +1112,14 @@ class LiquidFillerGroup {
|
|
|
997
1112
|
animate();
|
|
998
1113
|
}
|
|
999
1114
|
catch (error) {
|
|
1000
|
-
console.error('LiquidFillerGroup: fillTo
|
|
1115
|
+
console.error('LiquidFillerGroup: fillTo execution failed', model, error);
|
|
1001
1116
|
}
|
|
1002
1117
|
});
|
|
1003
1118
|
}
|
|
1004
|
-
/**
|
|
1119
|
+
/** Set multiple model levels, percentList corresponds to items order */
|
|
1005
1120
|
fillToAll(percentList) {
|
|
1006
1121
|
if (percentList.length !== this.items.length) {
|
|
1007
|
-
console.warn(`LiquidFillerGroup: percentList
|
|
1122
|
+
console.warn(`LiquidFillerGroup: percentList length (${percentList.length}) does not match items length (${this.items.length})`);
|
|
1008
1123
|
}
|
|
1009
1124
|
percentList.forEach((p, idx) => {
|
|
1010
1125
|
if (idx < this.items.length) {
|
|
@@ -1012,17 +1127,17 @@ class LiquidFillerGroup {
|
|
|
1012
1127
|
}
|
|
1013
1128
|
});
|
|
1014
1129
|
}
|
|
1015
|
-
/**
|
|
1130
|
+
/** Restore single model original material and remove liquid */
|
|
1016
1131
|
restore(model) {
|
|
1017
1132
|
const item = this.items.find(i => i.model === model);
|
|
1018
1133
|
if (!item)
|
|
1019
1134
|
return;
|
|
1020
|
-
//
|
|
1135
|
+
// Cancel animation
|
|
1021
1136
|
if (item.animationId !== null) {
|
|
1022
1137
|
cancelAnimationFrame(item.animationId);
|
|
1023
1138
|
item.animationId = null;
|
|
1024
1139
|
}
|
|
1025
|
-
//
|
|
1140
|
+
// Restore original material
|
|
1026
1141
|
item.model.traverse(obj => {
|
|
1027
1142
|
if (obj.isMesh) {
|
|
1028
1143
|
const mesh = obj;
|
|
@@ -1031,7 +1146,7 @@ class LiquidFillerGroup {
|
|
|
1031
1146
|
mesh.material = original;
|
|
1032
1147
|
}
|
|
1033
1148
|
});
|
|
1034
|
-
//
|
|
1149
|
+
// Dispose liquid Mesh
|
|
1035
1150
|
if (item.liquidMesh) {
|
|
1036
1151
|
this.scene.remove(item.liquidMesh);
|
|
1037
1152
|
item.liquidMesh.geometry.dispose();
|
|
@@ -1044,50 +1159,60 @@ class LiquidFillerGroup {
|
|
|
1044
1159
|
item.liquidMesh = null;
|
|
1045
1160
|
}
|
|
1046
1161
|
}
|
|
1047
|
-
/**
|
|
1162
|
+
/** Restore all models */
|
|
1048
1163
|
restoreAll() {
|
|
1049
1164
|
this.items.forEach(item => this.restore(item.model));
|
|
1050
1165
|
}
|
|
1051
|
-
/**
|
|
1166
|
+
/** Dispose method, release events and resources */
|
|
1052
1167
|
dispose() {
|
|
1053
|
-
//
|
|
1168
|
+
// Restore all models first
|
|
1054
1169
|
this.restoreAll();
|
|
1055
|
-
//
|
|
1170
|
+
// Unbind all events at once using AbortController
|
|
1056
1171
|
if (this.abortController) {
|
|
1057
1172
|
this.abortController.abort();
|
|
1058
1173
|
this.abortController = null;
|
|
1059
1174
|
}
|
|
1060
|
-
//
|
|
1175
|
+
// Clear items
|
|
1061
1176
|
this.items.length = 0;
|
|
1062
1177
|
}
|
|
1063
1178
|
}
|
|
1064
1179
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1180
|
+
/**
|
|
1181
|
+
* @file followModels.ts
|
|
1182
|
+
* @description
|
|
1183
|
+
* Camera utility to automatically follow and focus on 3D models.
|
|
1184
|
+
* It smoothly moves the camera to an optimal viewing position relative to the target object(s).
|
|
1185
|
+
*
|
|
1186
|
+
* @best-practice
|
|
1187
|
+
* - Use `followModels` to focus on a newly selected object.
|
|
1188
|
+
* - Call `cancelFollow` before starting a new manual camera interaction if needed.
|
|
1189
|
+
* - Adjust `padding` to control how tight the camera framing is.
|
|
1190
|
+
*/
|
|
1191
|
+
// Use WeakMap to track animations, allowing for cancellation
|
|
1067
1192
|
const _animationMap = new WeakMap();
|
|
1068
1193
|
/**
|
|
1069
|
-
*
|
|
1194
|
+
* Recommended camera angles for quick selection of common views
|
|
1070
1195
|
*/
|
|
1071
1196
|
const FOLLOW_ANGLES = {
|
|
1072
|
-
/**
|
|
1197
|
+
/** Isometric view (default) - suitable for architecture, mechanical equipment */
|
|
1073
1198
|
ISOMETRIC: { azimuth: Math.PI / 4, elevation: Math.PI / 4 },
|
|
1074
|
-
/**
|
|
1199
|
+
/** Front view - suitable for frontal display, UI alignment */
|
|
1075
1200
|
FRONT: { azimuth: 0, elevation: 0 },
|
|
1076
|
-
/**
|
|
1201
|
+
/** Right view - suitable for mechanical sections, side inspection */
|
|
1077
1202
|
RIGHT: { azimuth: Math.PI / 2, elevation: 0 },
|
|
1078
|
-
/**
|
|
1203
|
+
/** Left view */
|
|
1079
1204
|
LEFT: { azimuth: -Math.PI / 2, elevation: 0 },
|
|
1080
|
-
/**
|
|
1205
|
+
/** Back view */
|
|
1081
1206
|
BACK: { azimuth: Math.PI, elevation: 0 },
|
|
1082
|
-
/**
|
|
1207
|
+
/** Top view - suitable for maps, layout display */
|
|
1083
1208
|
TOP: { azimuth: 0, elevation: Math.PI / 2 },
|
|
1084
|
-
/**
|
|
1209
|
+
/** Low angle view - suitable for vehicles, characters near the ground */
|
|
1085
1210
|
LOW_ANGLE: { azimuth: Math.PI / 4, elevation: Math.PI / 6 },
|
|
1086
|
-
/**
|
|
1211
|
+
/** High angle view - suitable for bird's eye view, panoramic browsing */
|
|
1087
1212
|
HIGH_ANGLE: { azimuth: Math.PI / 4, elevation: Math.PI / 3 }
|
|
1088
1213
|
};
|
|
1089
1214
|
/**
|
|
1090
|
-
*
|
|
1215
|
+
* Collection of easing functions
|
|
1091
1216
|
*/
|
|
1092
1217
|
const EASING_FUNCTIONS = {
|
|
1093
1218
|
linear: (t) => t,
|
|
@@ -1096,20 +1221,20 @@ const EASING_FUNCTIONS = {
|
|
|
1096
1221
|
easeIn: (t) => t * t * t
|
|
1097
1222
|
};
|
|
1098
1223
|
/**
|
|
1099
|
-
*
|
|
1224
|
+
* Automatically moves the camera to a diagonal position relative to the target,
|
|
1225
|
+
* ensuring the target is within the field of view (smooth transition).
|
|
1100
1226
|
*
|
|
1101
|
-
*
|
|
1102
|
-
* -
|
|
1103
|
-
* -
|
|
1104
|
-
* -
|
|
1105
|
-
* - WeakMap
|
|
1106
|
-
* -
|
|
1227
|
+
* Features:
|
|
1228
|
+
* - Supports multiple easing functions
|
|
1229
|
+
* - Adds progress callback
|
|
1230
|
+
* - Supports animation cancellation
|
|
1231
|
+
* - Uses WeakMap to track and prevent memory leaks
|
|
1232
|
+
* - Robust error handling
|
|
1107
1233
|
*/
|
|
1108
1234
|
function followModels(camera, targets, options = {}) {
|
|
1109
|
-
|
|
1110
|
-
// ✨ 取消之前的动画
|
|
1235
|
+
// Cancel previous animation
|
|
1111
1236
|
cancelFollow(camera);
|
|
1112
|
-
//
|
|
1237
|
+
// Boundary check
|
|
1113
1238
|
const arr = [];
|
|
1114
1239
|
if (!targets)
|
|
1115
1240
|
return Promise.resolve();
|
|
@@ -1118,31 +1243,31 @@ function followModels(camera, targets, options = {}) {
|
|
|
1118
1243
|
else
|
|
1119
1244
|
arr.push(targets);
|
|
1120
1245
|
if (arr.length === 0) {
|
|
1121
|
-
console.warn('followModels:
|
|
1246
|
+
console.warn('followModels: Target object is empty');
|
|
1122
1247
|
return Promise.resolve();
|
|
1123
1248
|
}
|
|
1124
1249
|
try {
|
|
1125
1250
|
const box = new THREE.Box3();
|
|
1126
1251
|
arr.forEach((o) => box.expandByObject(o));
|
|
1127
|
-
//
|
|
1252
|
+
// Check bounding box validity
|
|
1128
1253
|
if (!isFinite(box.min.x) || !isFinite(box.max.x)) {
|
|
1129
|
-
console.warn('followModels:
|
|
1254
|
+
console.warn('followModels: Failed to calculate bounding box');
|
|
1130
1255
|
return Promise.resolve();
|
|
1131
1256
|
}
|
|
1132
1257
|
const sphere = new THREE.Sphere();
|
|
1133
1258
|
box.getBoundingSphere(sphere);
|
|
1134
1259
|
const center = sphere.center.clone();
|
|
1135
1260
|
const radiusBase = Math.max(0.001, sphere.radius);
|
|
1136
|
-
const duration =
|
|
1137
|
-
const padding =
|
|
1261
|
+
const duration = options.duration ?? 700;
|
|
1262
|
+
const padding = options.padding ?? 1.0;
|
|
1138
1263
|
const minDistance = options.minDistance;
|
|
1139
1264
|
const maxDistance = options.maxDistance;
|
|
1140
|
-
const controls =
|
|
1141
|
-
const azimuth =
|
|
1142
|
-
const elevation =
|
|
1143
|
-
const easing =
|
|
1265
|
+
const controls = options.controls ?? null;
|
|
1266
|
+
const azimuth = options.azimuth ?? Math.PI / 4;
|
|
1267
|
+
const elevation = options.elevation ?? Math.PI / 4;
|
|
1268
|
+
const easing = options.easing ?? 'easeOut';
|
|
1144
1269
|
const onProgress = options.onProgress;
|
|
1145
|
-
//
|
|
1270
|
+
// Get easing function
|
|
1146
1271
|
const easingFn = EASING_FUNCTIONS[easing] || EASING_FUNCTIONS.easeOut;
|
|
1147
1272
|
let distance = 10;
|
|
1148
1273
|
if (camera.isPerspectiveCamera) {
|
|
@@ -1162,7 +1287,7 @@ function followModels(camera, targets, options = {}) {
|
|
|
1162
1287
|
else {
|
|
1163
1288
|
distance = camera.position.distanceTo(center);
|
|
1164
1289
|
}
|
|
1165
|
-
//
|
|
1290
|
+
// Calculate direction based on azimuth / elevation
|
|
1166
1291
|
const hx = Math.sin(azimuth);
|
|
1167
1292
|
const hz = Math.cos(azimuth);
|
|
1168
1293
|
const dir = new THREE.Vector3(hx * Math.cos(elevation), Math.sin(elevation), hz * Math.cos(elevation)).normalize();
|
|
@@ -1175,7 +1300,6 @@ function followModels(camera, targets, options = {}) {
|
|
|
1175
1300
|
const startTime = performance.now();
|
|
1176
1301
|
return new Promise((resolve) => {
|
|
1177
1302
|
const step = (now) => {
|
|
1178
|
-
var _a;
|
|
1179
1303
|
const elapsed = now - startTime;
|
|
1180
1304
|
const t = Math.min(1, duration > 0 ? elapsed / duration : 1);
|
|
1181
1305
|
const k = easingFn(t);
|
|
@@ -1189,14 +1313,16 @@ function followModels(camera, targets, options = {}) {
|
|
|
1189
1313
|
else {
|
|
1190
1314
|
camera.lookAt(endTarget);
|
|
1191
1315
|
}
|
|
1192
|
-
(
|
|
1193
|
-
|
|
1316
|
+
if (camera.updateProjectionMatrix) {
|
|
1317
|
+
camera.updateProjectionMatrix();
|
|
1318
|
+
}
|
|
1319
|
+
// Call progress callback
|
|
1194
1320
|
if (onProgress) {
|
|
1195
1321
|
try {
|
|
1196
1322
|
onProgress(t);
|
|
1197
1323
|
}
|
|
1198
1324
|
catch (error) {
|
|
1199
|
-
console.error('followModels:
|
|
1325
|
+
console.error('followModels: Progress callback error', error);
|
|
1200
1326
|
}
|
|
1201
1327
|
}
|
|
1202
1328
|
if (t < 1) {
|
|
@@ -1222,12 +1348,12 @@ function followModels(camera, targets, options = {}) {
|
|
|
1222
1348
|
});
|
|
1223
1349
|
}
|
|
1224
1350
|
catch (error) {
|
|
1225
|
-
console.error('followModels:
|
|
1351
|
+
console.error('followModels: Execution failed', error);
|
|
1226
1352
|
return Promise.reject(error);
|
|
1227
1353
|
}
|
|
1228
1354
|
}
|
|
1229
1355
|
/**
|
|
1230
|
-
*
|
|
1356
|
+
* Cancel the camera follow animation
|
|
1231
1357
|
*/
|
|
1232
1358
|
function cancelFollow(camera) {
|
|
1233
1359
|
const rafId = _animationMap.get(camera);
|
|
@@ -1237,42 +1363,47 @@ function cancelFollow(camera) {
|
|
|
1237
1363
|
}
|
|
1238
1364
|
}
|
|
1239
1365
|
|
|
1240
|
-
// src/utils/setView.ts - 优化版
|
|
1241
1366
|
/**
|
|
1242
|
-
*
|
|
1367
|
+
* @file setView.ts
|
|
1368
|
+
* @description
|
|
1369
|
+
* Utility to smoothly transition the camera to preset views (Front, Back, Top, Isometric, etc.).
|
|
1370
|
+
*
|
|
1371
|
+
* @best-practice
|
|
1372
|
+
* - Use `setView` for UI buttons that switch camera angles.
|
|
1373
|
+
* - Leverage `ViewPresets` for readable code when using standard views.
|
|
1374
|
+
*/
|
|
1375
|
+
/**
|
|
1376
|
+
* Smoothly switches the camera to the optimal angle for the model.
|
|
1243
1377
|
*
|
|
1244
|
-
*
|
|
1245
|
-
* -
|
|
1246
|
-
* -
|
|
1247
|
-
* -
|
|
1248
|
-
* -
|
|
1249
|
-
* -
|
|
1378
|
+
* Features:
|
|
1379
|
+
* - Reuses followModels logic to avoid code duplication
|
|
1380
|
+
* - Supports more angles
|
|
1381
|
+
* - Enhanced configuration options
|
|
1382
|
+
* - Returns Promise to support chaining
|
|
1383
|
+
* - Supports animation cancellation
|
|
1250
1384
|
*
|
|
1251
|
-
* @param camera THREE.PerspectiveCamera
|
|
1252
|
-
* @param controls OrbitControls
|
|
1253
|
-
* @param targetObj THREE.Object3D
|
|
1254
|
-
* @param position
|
|
1255
|
-
* @param options
|
|
1385
|
+
* @param camera THREE.PerspectiveCamera instance
|
|
1386
|
+
* @param controls OrbitControls instance
|
|
1387
|
+
* @param targetObj THREE.Object3D model object
|
|
1388
|
+
* @param position View position
|
|
1389
|
+
* @param options Configuration options
|
|
1256
1390
|
* @returns Promise<void>
|
|
1257
1391
|
*/
|
|
1258
1392
|
function setView(camera, controls, targetObj, position = 'front', options = {}) {
|
|
1259
1393
|
const { distanceFactor = 0.8, duration = 1000, easing = 'easeInOut', onProgress } = options;
|
|
1260
|
-
//
|
|
1394
|
+
// Boundary check
|
|
1261
1395
|
if (!targetObj) {
|
|
1262
|
-
console.warn('setView:
|
|
1396
|
+
console.warn('setView: Target object is empty');
|
|
1263
1397
|
return Promise.reject(new Error('Target object is required'));
|
|
1264
1398
|
}
|
|
1265
1399
|
try {
|
|
1266
|
-
//
|
|
1400
|
+
// Calculate bounding box
|
|
1267
1401
|
const box = new THREE.Box3().setFromObject(targetObj);
|
|
1268
1402
|
if (!isFinite(box.min.x)) {
|
|
1269
|
-
console.warn('setView:
|
|
1403
|
+
console.warn('setView: Failed to calculate bounding box');
|
|
1270
1404
|
return Promise.reject(new Error('Invalid bounding box'));
|
|
1271
1405
|
}
|
|
1272
|
-
|
|
1273
|
-
const size = box.getSize(new THREE.Vector3());
|
|
1274
|
-
const maxSize = Math.max(size.x, size.y, size.z);
|
|
1275
|
-
// ✨ 使用映射表简化视角计算
|
|
1406
|
+
// Use mapping table for creating view angles
|
|
1276
1407
|
const viewAngles = {
|
|
1277
1408
|
'front': { azimuth: 0, elevation: 0 },
|
|
1278
1409
|
'back': { azimuth: Math.PI, elevation: 0 },
|
|
@@ -1283,7 +1414,7 @@ function setView(camera, controls, targetObj, position = 'front', options = {})
|
|
|
1283
1414
|
'iso': { azimuth: Math.PI / 4, elevation: Math.PI / 4 }
|
|
1284
1415
|
};
|
|
1285
1416
|
const angle = viewAngles[position] || viewAngles.front;
|
|
1286
|
-
//
|
|
1417
|
+
// Reuse followModels to avoid code duplication
|
|
1287
1418
|
return followModels(camera, targetObj, {
|
|
1288
1419
|
duration,
|
|
1289
1420
|
padding: distanceFactor,
|
|
@@ -1295,191 +1426,194 @@ function setView(camera, controls, targetObj, position = 'front', options = {})
|
|
|
1295
1426
|
});
|
|
1296
1427
|
}
|
|
1297
1428
|
catch (error) {
|
|
1298
|
-
console.error('setView:
|
|
1429
|
+
console.error('setView: Execution failed', error);
|
|
1299
1430
|
return Promise.reject(error);
|
|
1300
1431
|
}
|
|
1301
1432
|
}
|
|
1302
1433
|
/**
|
|
1303
|
-
*
|
|
1434
|
+
* Cancel view switch animation
|
|
1304
1435
|
*/
|
|
1305
1436
|
function cancelSetView(camera) {
|
|
1306
1437
|
cancelFollow(camera);
|
|
1307
1438
|
}
|
|
1308
1439
|
/**
|
|
1309
|
-
*
|
|
1440
|
+
* Preset view shortcut methods
|
|
1310
1441
|
*/
|
|
1311
1442
|
const ViewPresets = {
|
|
1312
1443
|
/**
|
|
1313
|
-
*
|
|
1444
|
+
* Front View
|
|
1314
1445
|
*/
|
|
1315
1446
|
front: (camera, controls, target, options) => setView(camera, controls, target, 'front', options),
|
|
1316
1447
|
/**
|
|
1317
|
-
*
|
|
1448
|
+
* Isometric View
|
|
1318
1449
|
*/
|
|
1319
1450
|
isometric: (camera, controls, target, options) => setView(camera, controls, target, 'iso', options),
|
|
1320
1451
|
/**
|
|
1321
|
-
*
|
|
1452
|
+
* Top View
|
|
1322
1453
|
*/
|
|
1323
1454
|
top: (camera, controls, target, options) => setView(camera, controls, target, 'top', options)
|
|
1324
1455
|
};
|
|
1325
1456
|
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
Permission to use, copy, modify, and/or distribute this software for any
|
|
1330
|
-
purpose with or without fee is hereby granted.
|
|
1331
|
-
|
|
1332
|
-
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
1333
|
-
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
1334
|
-
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
1335
|
-
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
1336
|
-
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
1337
|
-
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
1338
|
-
PERFORMANCE OF THIS SOFTWARE.
|
|
1339
|
-
***************************************************************************** */
|
|
1340
|
-
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
function __awaiter(thisArg, _arguments, P, generator) {
|
|
1344
|
-
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
1345
|
-
return new (P || (P = Promise))(function (resolve, reject) {
|
|
1346
|
-
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
1347
|
-
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
1348
|
-
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
1349
|
-
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
1350
|
-
});
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
1354
|
-
var e = new Error(message);
|
|
1355
|
-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
1457
|
+
let globalConfig = {
|
|
1458
|
+
dracoDecoderPath: '/draco/',
|
|
1459
|
+
ktx2TranscoderPath: '/basis/',
|
|
1356
1460
|
};
|
|
1461
|
+
/**
|
|
1462
|
+
* Update global loader configuration (e.g., set path to CDN)
|
|
1463
|
+
*/
|
|
1464
|
+
function setLoaderConfig(config) {
|
|
1465
|
+
globalConfig = { ...globalConfig, ...config };
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Get current global loader configuration
|
|
1469
|
+
*/
|
|
1470
|
+
function getLoaderConfig() {
|
|
1471
|
+
return globalConfig;
|
|
1472
|
+
}
|
|
1357
1473
|
|
|
1474
|
+
/**
|
|
1475
|
+
* @file modelLoader.ts
|
|
1476
|
+
* @description
|
|
1477
|
+
* Utility to load 3D models (GLTF, FBX, OBJ, PLY, STL) from URLs.
|
|
1478
|
+
*
|
|
1479
|
+
* @best-practice
|
|
1480
|
+
* - Use `loadModelByUrl` for a unified loading interface.
|
|
1481
|
+
* - Supports Draco compression and KTX2 textures for GLTF.
|
|
1482
|
+
* - Includes optimization options like geometry merging and texture downscaling.
|
|
1483
|
+
*/
|
|
1358
1484
|
const DEFAULT_OPTIONS$1 = {
|
|
1359
1485
|
useKTX2: false,
|
|
1360
1486
|
mergeGeometries: false,
|
|
1361
1487
|
maxTextureSize: null,
|
|
1362
1488
|
useSimpleMaterials: false,
|
|
1363
1489
|
skipSkinned: true,
|
|
1490
|
+
useCache: true,
|
|
1364
1491
|
};
|
|
1365
|
-
|
|
1492
|
+
const modelCache = new Map();
|
|
1493
|
+
/** Automatically determine which options to enable based on extension (smart judgment) */
|
|
1366
1494
|
function normalizeOptions(url, opts) {
|
|
1367
1495
|
const ext = (url.split('.').pop() || '').toLowerCase();
|
|
1368
|
-
const merged =
|
|
1496
|
+
const merged = { ...DEFAULT_OPTIONS$1, ...opts };
|
|
1369
1497
|
if (ext === 'gltf' || ext === 'glb') {
|
|
1370
|
-
|
|
1498
|
+
const globalConfig = getLoaderConfig();
|
|
1499
|
+
// gltf/glb defaults to trying draco/ktx2 if user didn't specify
|
|
1371
1500
|
if (merged.dracoDecoderPath === undefined)
|
|
1372
|
-
merged.dracoDecoderPath =
|
|
1501
|
+
merged.dracoDecoderPath = globalConfig.dracoDecoderPath;
|
|
1373
1502
|
if (merged.useKTX2 === undefined)
|
|
1374
1503
|
merged.useKTX2 = true;
|
|
1375
1504
|
if (merged.ktx2TranscoderPath === undefined)
|
|
1376
|
-
merged.ktx2TranscoderPath =
|
|
1505
|
+
merged.ktx2TranscoderPath = globalConfig.ktx2TranscoderPath;
|
|
1377
1506
|
}
|
|
1378
1507
|
else {
|
|
1379
|
-
// fbx/obj/ply/stl
|
|
1508
|
+
// fbx/obj/ply/stl etc. do not need draco/ktx2
|
|
1380
1509
|
merged.dracoDecoderPath = null;
|
|
1381
1510
|
merged.ktx2TranscoderPath = null;
|
|
1382
1511
|
merged.useKTX2 = false;
|
|
1383
1512
|
}
|
|
1384
1513
|
return merged;
|
|
1385
1514
|
}
|
|
1386
|
-
function loadModelByUrl(
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
const ktx2Loader = new KTX2Loader().setTranscoderPath(opts.ktx2TranscoderPath);
|
|
1407
|
-
gltfLoader.__ktx2Loader = ktx2Loader;
|
|
1408
|
-
}
|
|
1409
|
-
loader = gltfLoader;
|
|
1410
|
-
}
|
|
1411
|
-
else if (ext === 'fbx') {
|
|
1412
|
-
const { FBXLoader } = yield import('three/examples/jsm/loaders/FBXLoader.js');
|
|
1413
|
-
loader = new FBXLoader(manager);
|
|
1414
|
-
}
|
|
1415
|
-
else if (ext === 'obj') {
|
|
1416
|
-
const { OBJLoader } = yield import('three/examples/jsm/loaders/OBJLoader.js');
|
|
1417
|
-
loader = new OBJLoader(manager);
|
|
1418
|
-
}
|
|
1419
|
-
else if (ext === 'ply') {
|
|
1420
|
-
const { PLYLoader } = yield import('three/examples/jsm/loaders/PLYLoader.js');
|
|
1421
|
-
loader = new PLYLoader(manager);
|
|
1422
|
-
}
|
|
1423
|
-
else if (ext === 'stl') {
|
|
1424
|
-
const { STLLoader } = yield import('three/examples/jsm/loaders/STLLoader.js');
|
|
1425
|
-
loader = new STLLoader(manager);
|
|
1515
|
+
async function loadModelByUrl(url, options = {}) {
|
|
1516
|
+
if (!url)
|
|
1517
|
+
throw new Error('url required');
|
|
1518
|
+
const ext = (url.split('.').pop() || '').toLowerCase();
|
|
1519
|
+
const opts = normalizeOptions(url, options);
|
|
1520
|
+
const manager = opts.manager ?? new THREE.LoadingManager();
|
|
1521
|
+
// Cache key includes URL and relevant optimization options
|
|
1522
|
+
const cacheKey = `${url}_${opts.mergeGeometries}_${opts.maxTextureSize}_${opts.useSimpleMaterials}`;
|
|
1523
|
+
if (opts.useCache && modelCache.has(cacheKey)) {
|
|
1524
|
+
return modelCache.get(cacheKey).clone();
|
|
1525
|
+
}
|
|
1526
|
+
let loader;
|
|
1527
|
+
if (ext === 'gltf' || ext === 'glb') {
|
|
1528
|
+
const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
|
1529
|
+
const gltfLoader = new GLTFLoader(manager);
|
|
1530
|
+
if (opts.dracoDecoderPath) {
|
|
1531
|
+
const { DRACOLoader } = await import('three/examples/jsm/loaders/DRACOLoader.js');
|
|
1532
|
+
const draco = new DRACOLoader();
|
|
1533
|
+
draco.setDecoderPath(opts.dracoDecoderPath);
|
|
1534
|
+
gltfLoader.setDRACOLoader(draco);
|
|
1426
1535
|
}
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
loader.load(url, (res) => {
|
|
1432
|
-
var _a;
|
|
1433
|
-
if (ext === 'gltf' || ext === 'glb') {
|
|
1434
|
-
const sceneObj = res.scene || res;
|
|
1435
|
-
// --- 关键:把 animations 暴露到 scene.userData(或 scene.animations)上 ---
|
|
1436
|
-
// 这样调用方只要拿到 sceneObj,就能通过 sceneObj.userData.animations 读取到 clips
|
|
1437
|
-
sceneObj.userData = (sceneObj === null || sceneObj === void 0 ? void 0 : sceneObj.userData) || {};
|
|
1438
|
-
sceneObj.userData.animations = (_a = res.animations) !== null && _a !== void 0 ? _a : [];
|
|
1439
|
-
resolve(sceneObj);
|
|
1440
|
-
}
|
|
1441
|
-
else {
|
|
1442
|
-
resolve(res);
|
|
1443
|
-
}
|
|
1444
|
-
}, undefined, (err) => reject(err));
|
|
1445
|
-
});
|
|
1446
|
-
// 优化
|
|
1447
|
-
object.traverse((child) => {
|
|
1448
|
-
var _a, _b, _c;
|
|
1449
|
-
const mesh = child;
|
|
1450
|
-
if (mesh.isMesh && mesh.geometry && !mesh.geometry.isBufferGeometry) {
|
|
1451
|
-
try {
|
|
1452
|
-
mesh.geometry = (_c = (_b = (_a = new THREE.BufferGeometry()).fromGeometry) === null || _b === void 0 ? void 0 : _b.call(_a, mesh.geometry)) !== null && _c !== void 0 ? _c : mesh.geometry;
|
|
1453
|
-
}
|
|
1454
|
-
catch (_d) { }
|
|
1455
|
-
}
|
|
1456
|
-
});
|
|
1457
|
-
if (opts.maxTextureSize && opts.maxTextureSize > 0)
|
|
1458
|
-
downscaleTexturesInObject(object, opts.maxTextureSize);
|
|
1459
|
-
if (opts.useSimpleMaterials) {
|
|
1460
|
-
object.traverse((child) => {
|
|
1461
|
-
const m = child.material;
|
|
1462
|
-
if (!m)
|
|
1463
|
-
return;
|
|
1464
|
-
if (Array.isArray(m))
|
|
1465
|
-
child.material = m.map((mat) => toSimpleMaterial(mat));
|
|
1466
|
-
else
|
|
1467
|
-
child.material = toSimpleMaterial(m);
|
|
1468
|
-
});
|
|
1536
|
+
if (opts.useKTX2 && opts.ktx2TranscoderPath) {
|
|
1537
|
+
const { KTX2Loader } = await import('three/examples/jsm/loaders/KTX2Loader.js');
|
|
1538
|
+
const ktx2Loader = new KTX2Loader().setTranscoderPath(opts.ktx2TranscoderPath);
|
|
1539
|
+
gltfLoader.__ktx2Loader = ktx2Loader;
|
|
1469
1540
|
}
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1541
|
+
loader = gltfLoader;
|
|
1542
|
+
}
|
|
1543
|
+
else if (ext === 'fbx') {
|
|
1544
|
+
const { FBXLoader } = await import('three/examples/jsm/loaders/FBXLoader.js');
|
|
1545
|
+
loader = new FBXLoader(manager);
|
|
1546
|
+
}
|
|
1547
|
+
else if (ext === 'obj') {
|
|
1548
|
+
const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js');
|
|
1549
|
+
loader = new OBJLoader(manager);
|
|
1550
|
+
}
|
|
1551
|
+
else if (ext === 'ply') {
|
|
1552
|
+
const { PLYLoader } = await import('three/examples/jsm/loaders/PLYLoader.js');
|
|
1553
|
+
loader = new PLYLoader(manager);
|
|
1554
|
+
}
|
|
1555
|
+
else if (ext === 'stl') {
|
|
1556
|
+
const { STLLoader } = await import('three/examples/jsm/loaders/STLLoader.js');
|
|
1557
|
+
loader = new STLLoader(manager);
|
|
1558
|
+
}
|
|
1559
|
+
else {
|
|
1560
|
+
throw new Error(`Unsupported model extension: .${ext}`);
|
|
1561
|
+
}
|
|
1562
|
+
const object = await new Promise((resolve, reject) => {
|
|
1563
|
+
loader.load(url, (res) => {
|
|
1564
|
+
if (ext === 'gltf' || ext === 'glb') {
|
|
1565
|
+
const sceneObj = res.scene || res;
|
|
1566
|
+
// --- Critical: Expose animations to scene.userData (or scene.animations) ---
|
|
1567
|
+
// So the caller can access clips simply by getting sceneObj.userData.animations
|
|
1568
|
+
sceneObj.userData = sceneObj?.userData || {};
|
|
1569
|
+
sceneObj.userData.animations = res.animations ?? [];
|
|
1570
|
+
resolve(sceneObj);
|
|
1473
1571
|
}
|
|
1474
|
-
|
|
1475
|
-
|
|
1572
|
+
else {
|
|
1573
|
+
resolve(res);
|
|
1574
|
+
}
|
|
1575
|
+
}, undefined, (err) => reject(err));
|
|
1576
|
+
});
|
|
1577
|
+
// Optimize
|
|
1578
|
+
object.traverse((child) => {
|
|
1579
|
+
const mesh = child;
|
|
1580
|
+
if (mesh.isMesh && mesh.geometry && !mesh.geometry.isBufferGeometry) {
|
|
1581
|
+
try {
|
|
1582
|
+
mesh.geometry = new THREE.BufferGeometry().fromGeometry?.(mesh.geometry) ?? mesh.geometry;
|
|
1476
1583
|
}
|
|
1584
|
+
catch { }
|
|
1477
1585
|
}
|
|
1478
|
-
return object;
|
|
1479
1586
|
});
|
|
1587
|
+
if (opts.maxTextureSize && opts.maxTextureSize > 0)
|
|
1588
|
+
await downscaleTexturesInObject(object, opts.maxTextureSize);
|
|
1589
|
+
if (opts.useSimpleMaterials) {
|
|
1590
|
+
object.traverse((child) => {
|
|
1591
|
+
const m = child.material;
|
|
1592
|
+
if (!m)
|
|
1593
|
+
return;
|
|
1594
|
+
if (Array.isArray(m))
|
|
1595
|
+
child.material = m.map((mat) => toSimpleMaterial(mat));
|
|
1596
|
+
else
|
|
1597
|
+
child.material = toSimpleMaterial(m);
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
if (opts.mergeGeometries) {
|
|
1601
|
+
try {
|
|
1602
|
+
await tryMergeGeometries(object, { skipSkinned: opts.skipSkinned ?? true });
|
|
1603
|
+
}
|
|
1604
|
+
catch (e) {
|
|
1605
|
+
console.warn('mergeGeometries failed', e);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
if (opts.useCache) {
|
|
1609
|
+
modelCache.set(cacheKey, object);
|
|
1610
|
+
return object.clone();
|
|
1611
|
+
}
|
|
1612
|
+
return object;
|
|
1480
1613
|
}
|
|
1481
|
-
/**
|
|
1482
|
-
function downscaleTexturesInObject(obj, maxSize) {
|
|
1614
|
+
/** Runtime downscale textures in mesh to maxSize (createImageBitmap or canvas) to save GPU memory */
|
|
1615
|
+
async function downscaleTexturesInObject(obj, maxSize) {
|
|
1616
|
+
const tasks = [];
|
|
1483
1617
|
obj.traverse((ch) => {
|
|
1484
1618
|
if (!ch.isMesh)
|
|
1485
1619
|
return;
|
|
@@ -1498,115 +1632,128 @@ function downscaleTexturesInObject(obj, maxSize) {
|
|
|
1498
1632
|
const max = maxSize;
|
|
1499
1633
|
if (image.width <= max && image.height <= max)
|
|
1500
1634
|
return;
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1635
|
+
tasks.push((async () => {
|
|
1636
|
+
try {
|
|
1637
|
+
const scale = Math.min(max / image.width, max / image.height);
|
|
1638
|
+
const newWidth = Math.floor(image.width * scale);
|
|
1639
|
+
const newHeight = Math.floor(image.height * scale);
|
|
1640
|
+
let newSource;
|
|
1641
|
+
if (typeof createImageBitmap !== 'undefined') {
|
|
1642
|
+
newSource = await createImageBitmap(image, {
|
|
1643
|
+
resizeWidth: newWidth,
|
|
1644
|
+
resizeHeight: newHeight,
|
|
1645
|
+
resizeQuality: 'high'
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
else {
|
|
1649
|
+
// Fallback for environments without createImageBitmap
|
|
1650
|
+
const canvas = document.createElement('canvas');
|
|
1651
|
+
canvas.width = newWidth;
|
|
1652
|
+
canvas.height = newHeight;
|
|
1653
|
+
const ctx = canvas.getContext('2d');
|
|
1654
|
+
if (ctx) {
|
|
1655
|
+
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
|
1656
|
+
newSource = canvas;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
if (newSource) {
|
|
1660
|
+
const newTex = new THREE.Texture(newSource);
|
|
1661
|
+
newTex.needsUpdate = true;
|
|
1662
|
+
newTex.encoding = tex.encoding;
|
|
1663
|
+
mat[p] = newTex;
|
|
1664
|
+
}
|
|
1515
1665
|
}
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
}
|
|
1666
|
+
catch (e) {
|
|
1667
|
+
console.warn('downscale texture failed', e);
|
|
1668
|
+
}
|
|
1669
|
+
})());
|
|
1520
1670
|
});
|
|
1521
1671
|
});
|
|
1672
|
+
await Promise.all(tasks);
|
|
1522
1673
|
}
|
|
1523
1674
|
/**
|
|
1524
|
-
*
|
|
1525
|
-
* -
|
|
1526
|
-
* -
|
|
1527
|
-
* -
|
|
1675
|
+
* Try to merge geometries in object (Only merge: non-transparent, non-SkinnedMesh, attribute compatible BufferGeometry)
|
|
1676
|
+
* - Before merging, apply world matrix to each mesh's geometry (so merged geometry is in world space)
|
|
1677
|
+
* - Merging will group by material UUID (different materials cannot be merged)
|
|
1678
|
+
* - Merge function is compatible with common export names of BufferGeometryUtils
|
|
1528
1679
|
*/
|
|
1529
|
-
function tryMergeGeometries(root, opts) {
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
var _a;
|
|
1535
|
-
if (!ch.isMesh)
|
|
1536
|
-
return;
|
|
1537
|
-
const mesh = ch;
|
|
1538
|
-
if (opts.skipSkinned && mesh.isSkinnedMesh)
|
|
1539
|
-
return;
|
|
1540
|
-
const mat = mesh.material;
|
|
1541
|
-
// don't merge transparent or morph-enabled or skinned meshes
|
|
1542
|
-
if (!mesh.geometry || mesh.visible === false)
|
|
1543
|
-
return;
|
|
1544
|
-
if (mat && mat.transparent)
|
|
1545
|
-
return;
|
|
1546
|
-
const geom = mesh.geometry.clone();
|
|
1547
|
-
mesh.updateWorldMatrix(true, false);
|
|
1548
|
-
geom.applyMatrix4(mesh.matrixWorld);
|
|
1549
|
-
// ensure attributes compatible? we'll rely on merge function to return null if incompatible
|
|
1550
|
-
const key = (mat && mat.uuid) || 'default';
|
|
1551
|
-
const bucket = (_a = groups.get(key)) !== null && _a !== void 0 ? _a : { material: mat !== null && mat !== void 0 ? mat : new THREE.MeshStandardMaterial(), geoms: [] };
|
|
1552
|
-
bucket.geoms.push(geom);
|
|
1553
|
-
groups.set(key, bucket);
|
|
1554
|
-
// mark for removal (we'll remove meshes after)
|
|
1555
|
-
mesh.userData.__toRemoveForMerge = true;
|
|
1556
|
-
});
|
|
1557
|
-
if (groups.size === 0)
|
|
1680
|
+
async function tryMergeGeometries(root, opts) {
|
|
1681
|
+
// collect meshes by material uuid
|
|
1682
|
+
const groups = new Map();
|
|
1683
|
+
root.traverse((ch) => {
|
|
1684
|
+
if (!ch.isMesh)
|
|
1558
1685
|
return;
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
const
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
if (
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1686
|
+
const mesh = ch;
|
|
1687
|
+
if (opts.skipSkinned && mesh.isSkinnedMesh)
|
|
1688
|
+
return;
|
|
1689
|
+
const mat = mesh.material;
|
|
1690
|
+
// don't merge transparent or morph-enabled or skinned meshes
|
|
1691
|
+
if (!mesh.geometry || mesh.visible === false)
|
|
1692
|
+
return;
|
|
1693
|
+
if (mat && mat.transparent)
|
|
1694
|
+
return;
|
|
1695
|
+
const geom = mesh.geometry.clone();
|
|
1696
|
+
mesh.updateWorldMatrix(true, false);
|
|
1697
|
+
geom.applyMatrix4(mesh.matrixWorld);
|
|
1698
|
+
// ensure attributes compatible? we'll rely on merge function to return null if incompatible
|
|
1699
|
+
const key = (mat && mat.uuid) || 'default';
|
|
1700
|
+
const bucket = groups.get(key) ?? { material: mat ?? new THREE.MeshStandardMaterial(), geoms: [] };
|
|
1701
|
+
bucket.geoms.push(geom);
|
|
1702
|
+
groups.set(key, bucket);
|
|
1703
|
+
// mark for removal (we'll remove meshes after)
|
|
1704
|
+
mesh.userData.__toRemoveForMerge = true;
|
|
1705
|
+
});
|
|
1706
|
+
if (groups.size === 0)
|
|
1707
|
+
return;
|
|
1708
|
+
// dynamic import BufferGeometryUtils and find merge function name
|
|
1709
|
+
const bufUtilsMod = await import('three/examples/jsm/utils/BufferGeometryUtils.js');
|
|
1710
|
+
// use || chain (avoid mixing ?? with || without parentheses)
|
|
1711
|
+
const mergeFn = bufUtilsMod.mergeBufferGeometries ||
|
|
1712
|
+
bufUtilsMod.mergeGeometries ||
|
|
1713
|
+
bufUtilsMod.mergeBufferGeometries || // defensive duplicate
|
|
1714
|
+
bufUtilsMod.mergeGeometries;
|
|
1715
|
+
if (!mergeFn)
|
|
1716
|
+
throw new Error('No merge function found in BufferGeometryUtils');
|
|
1717
|
+
// for each group, try merge
|
|
1718
|
+
for (const [key, { material, geoms }] of groups) {
|
|
1719
|
+
if (geoms.length <= 1) {
|
|
1720
|
+
// nothing to merge
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
// call merge function - signature typically mergeBufferGeometries(array, useGroups)
|
|
1724
|
+
const merged = mergeFn(geoms, false);
|
|
1725
|
+
if (!merged) {
|
|
1726
|
+
console.warn('merge returned null for group', key);
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
// create merged mesh at root (world-space geometry already applied)
|
|
1730
|
+
const mergedMesh = new THREE.Mesh(merged, material);
|
|
1731
|
+
root.add(mergedMesh);
|
|
1732
|
+
}
|
|
1733
|
+
// now remove original meshes flagged for removal
|
|
1734
|
+
const toRemove = [];
|
|
1735
|
+
root.traverse((ch) => {
|
|
1736
|
+
if (ch.userData?.__toRemoveForMerge)
|
|
1737
|
+
toRemove.push(ch);
|
|
1738
|
+
});
|
|
1739
|
+
toRemove.forEach((m) => {
|
|
1740
|
+
if (m.parent)
|
|
1741
|
+
m.parent.remove(m);
|
|
1742
|
+
// free original resources (geometries already cloned/applied), but careful with shared materials
|
|
1743
|
+
if (m.isMesh) {
|
|
1744
|
+
const mm = m;
|
|
1745
|
+
try {
|
|
1746
|
+
mm.geometry.dispose();
|
|
1579
1747
|
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
root.add(mergedMesh);
|
|
1748
|
+
catch { }
|
|
1749
|
+
// we do NOT dispose material because it may be reused by mergedMesh
|
|
1583
1750
|
}
|
|
1584
|
-
// now remove original meshes flagged for removal
|
|
1585
|
-
const toRemove = [];
|
|
1586
|
-
root.traverse((ch) => {
|
|
1587
|
-
var _a;
|
|
1588
|
-
if ((_a = ch.userData) === null || _a === void 0 ? void 0 : _a.__toRemoveForMerge)
|
|
1589
|
-
toRemove.push(ch);
|
|
1590
|
-
});
|
|
1591
|
-
toRemove.forEach((m) => {
|
|
1592
|
-
if (m.parent)
|
|
1593
|
-
m.parent.remove(m);
|
|
1594
|
-
// free original resources (geometries already cloned/applied), but careful with shared materials
|
|
1595
|
-
if (m.isMesh) {
|
|
1596
|
-
const mm = m;
|
|
1597
|
-
try {
|
|
1598
|
-
mm.geometry.dispose();
|
|
1599
|
-
}
|
|
1600
|
-
catch (_a) { }
|
|
1601
|
-
// we do NOT dispose material because it may be reused by mergedMesh
|
|
1602
|
-
}
|
|
1603
|
-
});
|
|
1604
1751
|
});
|
|
1605
1752
|
}
|
|
1606
1753
|
/* ---------------------
|
|
1607
|
-
|
|
1754
|
+
Dispose Utils
|
|
1608
1755
|
--------------------- */
|
|
1609
|
-
/**
|
|
1756
|
+
/** Completely dispose object: geometry, material and its textures (Danger: shared resources will be disposed) */
|
|
1610
1757
|
function disposeObject(obj) {
|
|
1611
1758
|
if (!obj)
|
|
1612
1759
|
return;
|
|
@@ -1617,7 +1764,7 @@ function disposeObject(obj) {
|
|
|
1617
1764
|
try {
|
|
1618
1765
|
m.geometry.dispose();
|
|
1619
1766
|
}
|
|
1620
|
-
catch
|
|
1767
|
+
catch { }
|
|
1621
1768
|
}
|
|
1622
1769
|
const mat = m.material;
|
|
1623
1770
|
if (mat) {
|
|
@@ -1629,7 +1776,7 @@ function disposeObject(obj) {
|
|
|
1629
1776
|
}
|
|
1630
1777
|
});
|
|
1631
1778
|
}
|
|
1632
|
-
/**
|
|
1779
|
+
/** Dispose material and its textures */
|
|
1633
1780
|
function disposeMaterial(mat) {
|
|
1634
1781
|
if (!mat)
|
|
1635
1782
|
return;
|
|
@@ -1639,232 +1786,244 @@ function disposeMaterial(mat) {
|
|
|
1639
1786
|
try {
|
|
1640
1787
|
mat[k].dispose();
|
|
1641
1788
|
}
|
|
1642
|
-
catch
|
|
1789
|
+
catch { }
|
|
1643
1790
|
}
|
|
1644
1791
|
});
|
|
1645
1792
|
try {
|
|
1646
1793
|
if (typeof mat.dispose === 'function')
|
|
1647
1794
|
mat.dispose();
|
|
1648
1795
|
}
|
|
1649
|
-
catch
|
|
1796
|
+
catch { }
|
|
1797
|
+
}
|
|
1798
|
+
// Helper to convert to simple material (stub)
|
|
1799
|
+
function toSimpleMaterial(mat) {
|
|
1800
|
+
// Basic implementation, preserve color/map
|
|
1801
|
+
const m = new THREE.MeshBasicMaterial();
|
|
1802
|
+
if (mat.color)
|
|
1803
|
+
m.color.copy(mat.color);
|
|
1804
|
+
if (mat.map)
|
|
1805
|
+
m.map = mat.map;
|
|
1806
|
+
return m;
|
|
1650
1807
|
}
|
|
1651
1808
|
|
|
1652
|
-
/**
|
|
1809
|
+
/**
|
|
1810
|
+
* @file skyboxLoader.ts
|
|
1811
|
+
* @description
|
|
1812
|
+
* Utility for loading skyboxes (CubeTexture or Equirectangular/HDR).
|
|
1813
|
+
*
|
|
1814
|
+
* @best-practice
|
|
1815
|
+
* - Use `loadSkybox` for a unified interface.
|
|
1816
|
+
* - Supports internal caching to avoid reloading the same skybox.
|
|
1817
|
+
* - Can set background and environment map independently.
|
|
1818
|
+
*/
|
|
1819
|
+
/** Default Values */
|
|
1653
1820
|
const DEFAULT_OPTIONS = {
|
|
1654
1821
|
setAsBackground: true,
|
|
1655
1822
|
setAsEnvironment: true,
|
|
1656
1823
|
useSRGBEncoding: true,
|
|
1657
1824
|
cache: true
|
|
1658
1825
|
};
|
|
1659
|
-
/**
|
|
1826
|
+
/** Internal Cache: key -> { handle, refCount } */
|
|
1660
1827
|
const cubeCache = new Map();
|
|
1661
1828
|
const equirectCache = new Map();
|
|
1662
1829
|
/* -------------------------------------------
|
|
1663
|
-
|
|
1830
|
+
Public Function: Load skybox (Automatically choose cube or equirect)
|
|
1664
1831
|
------------------------------------------- */
|
|
1665
1832
|
/**
|
|
1666
|
-
*
|
|
1667
|
-
* @param renderer THREE.WebGLRenderer -
|
|
1833
|
+
* Load Cube Texture (6 images)
|
|
1834
|
+
* @param renderer THREE.WebGLRenderer - Used for PMREM generating environment map
|
|
1668
1835
|
* @param scene THREE.Scene
|
|
1669
|
-
* @param paths string[] 6
|
|
1836
|
+
* @param paths string[] 6 image paths, order: [px, nx, py, ny, pz, nz]
|
|
1670
1837
|
* @param opts SkyboxOptions
|
|
1671
1838
|
*/
|
|
1672
|
-
function loadCubeSkybox(
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
rec.refCount += 1;
|
|
1683
|
-
// reapply to scene (in case it was removed)
|
|
1684
|
-
if (options.setAsBackground)
|
|
1685
|
-
scene.background = rec.handle.backgroundTexture;
|
|
1686
|
-
if (options.setAsEnvironment && rec.handle.envRenderTarget)
|
|
1687
|
-
scene.environment = rec.handle.envRenderTarget.texture;
|
|
1688
|
-
return rec.handle;
|
|
1689
|
-
}
|
|
1690
|
-
// 加载立方体贴图
|
|
1691
|
-
const loader = new THREE.CubeTextureLoader();
|
|
1692
|
-
const texture = yield new Promise((resolve, reject) => {
|
|
1693
|
-
loader.load(paths, (tex) => resolve(tex), undefined, (err) => reject(err));
|
|
1694
|
-
});
|
|
1695
|
-
// 设置编码与映射
|
|
1696
|
-
if (options.useSRGBEncoding)
|
|
1697
|
-
texture.encoding = THREE.sRGBEncoding;
|
|
1698
|
-
texture.mapping = THREE.CubeReflectionMapping;
|
|
1699
|
-
// apply as background if required
|
|
1839
|
+
async function loadCubeSkybox(renderer, scene, paths, opts = {}) {
|
|
1840
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
1841
|
+
if (!Array.isArray(paths) || paths.length !== 6)
|
|
1842
|
+
throw new Error('cube skybox requires 6 image paths');
|
|
1843
|
+
const key = paths.join('|');
|
|
1844
|
+
// Cache handling
|
|
1845
|
+
if (options.cache && cubeCache.has(key)) {
|
|
1846
|
+
const rec = cubeCache.get(key);
|
|
1847
|
+
rec.refCount += 1;
|
|
1848
|
+
// reapply to scene (in case it was removed)
|
|
1700
1849
|
if (options.setAsBackground)
|
|
1701
|
-
scene.background =
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1850
|
+
scene.background = rec.handle.backgroundTexture;
|
|
1851
|
+
if (options.setAsEnvironment && rec.handle.envRenderTarget)
|
|
1852
|
+
scene.environment = rec.handle.envRenderTarget.texture;
|
|
1853
|
+
return rec.handle;
|
|
1854
|
+
}
|
|
1855
|
+
// Load cube texture
|
|
1856
|
+
const loader = new THREE.CubeTextureLoader();
|
|
1857
|
+
const texture = await new Promise((resolve, reject) => {
|
|
1858
|
+
loader.load(paths, (tex) => resolve(tex), undefined, (err) => reject(err));
|
|
1859
|
+
});
|
|
1860
|
+
// Set encoding and mapping
|
|
1861
|
+
if (options.useSRGBEncoding)
|
|
1862
|
+
texture.encoding = THREE.sRGBEncoding;
|
|
1863
|
+
texture.mapping = THREE.CubeReflectionMapping;
|
|
1864
|
+
// apply as background if required
|
|
1865
|
+
if (options.setAsBackground)
|
|
1866
|
+
scene.background = texture;
|
|
1867
|
+
// environment: use PMREM to produce a proper prefiltered env map for PBR
|
|
1868
|
+
let pmremGenerator = options.pmremGenerator ?? new THREE.PMREMGenerator(renderer);
|
|
1869
|
+
pmremGenerator.compileCubemapShader?.( /* optional */);
|
|
1870
|
+
// fromCubemap might be available in your three.js; fallback to fromEquirectangular approach if not
|
|
1871
|
+
let envRenderTarget = null;
|
|
1872
|
+
if (pmremGenerator.fromCubemap) {
|
|
1873
|
+
envRenderTarget = pmremGenerator.fromCubemap(texture);
|
|
1874
|
+
}
|
|
1875
|
+
else {
|
|
1876
|
+
// Fallback: render cube to env map by using generator.fromEquirectangular with a converted equirect if needed.
|
|
1877
|
+
// Simpler fallback: use the cube texture directly as environment (less correct for reflections).
|
|
1878
|
+
envRenderTarget = null;
|
|
1879
|
+
}
|
|
1880
|
+
if (options.setAsEnvironment) {
|
|
1881
|
+
if (envRenderTarget) {
|
|
1882
|
+
scene.environment = envRenderTarget.texture;
|
|
1709
1883
|
}
|
|
1710
1884
|
else {
|
|
1711
|
-
//
|
|
1712
|
-
|
|
1713
|
-
envRenderTarget = null;
|
|
1885
|
+
// fallback: use cube texture directly (works but not prefiltered)
|
|
1886
|
+
scene.environment = texture;
|
|
1714
1887
|
}
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1888
|
+
}
|
|
1889
|
+
const handle = {
|
|
1890
|
+
key,
|
|
1891
|
+
backgroundTexture: options.setAsBackground ? texture : null,
|
|
1892
|
+
envRenderTarget: envRenderTarget,
|
|
1893
|
+
pmremGenerator: options.pmremGenerator ? null : pmremGenerator, // only dispose if we created it
|
|
1894
|
+
setAsBackground: !!options.setAsBackground,
|
|
1895
|
+
setAsEnvironment: !!options.setAsEnvironment,
|
|
1896
|
+
dispose() {
|
|
1897
|
+
// remove from scene
|
|
1898
|
+
if (options.setAsBackground && scene.background === texture)
|
|
1899
|
+
scene.background = null;
|
|
1900
|
+
if (options.setAsEnvironment && scene.environment) {
|
|
1901
|
+
// only clear if it's the same texture we set
|
|
1902
|
+
if (envRenderTarget && scene.environment === envRenderTarget.texture)
|
|
1903
|
+
scene.environment = null;
|
|
1904
|
+
else if (scene.environment === texture)
|
|
1905
|
+
scene.environment = null;
|
|
1718
1906
|
}
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
scene.environment = texture;
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
const handle = {
|
|
1725
|
-
key,
|
|
1726
|
-
backgroundTexture: options.setAsBackground ? texture : null,
|
|
1727
|
-
envRenderTarget: envRenderTarget,
|
|
1728
|
-
pmremGenerator: options.pmremGenerator ? null : pmremGenerator, // only dispose if we created it
|
|
1729
|
-
setAsBackground: !!options.setAsBackground,
|
|
1730
|
-
setAsEnvironment: !!options.setAsEnvironment,
|
|
1731
|
-
dispose() {
|
|
1732
|
-
// remove from scene
|
|
1733
|
-
if (options.setAsBackground && scene.background === texture)
|
|
1734
|
-
scene.background = null;
|
|
1735
|
-
if (options.setAsEnvironment && scene.environment) {
|
|
1736
|
-
// only clear if it's the same texture we set
|
|
1737
|
-
if (envRenderTarget && scene.environment === envRenderTarget.texture)
|
|
1738
|
-
scene.environment = null;
|
|
1739
|
-
else if (scene.environment === texture)
|
|
1740
|
-
scene.environment = null;
|
|
1741
|
-
}
|
|
1742
|
-
// dispose resources only if not cached/shared
|
|
1743
|
-
if (envRenderTarget) {
|
|
1744
|
-
try {
|
|
1745
|
-
envRenderTarget.dispose();
|
|
1746
|
-
}
|
|
1747
|
-
catch (_a) { }
|
|
1748
|
-
}
|
|
1907
|
+
// dispose resources only if not cached/shared
|
|
1908
|
+
if (envRenderTarget) {
|
|
1749
1909
|
try {
|
|
1750
|
-
|
|
1910
|
+
envRenderTarget.dispose();
|
|
1751
1911
|
}
|
|
1752
|
-
catch
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1912
|
+
catch { }
|
|
1913
|
+
}
|
|
1914
|
+
try {
|
|
1915
|
+
texture.dispose();
|
|
1916
|
+
}
|
|
1917
|
+
catch { }
|
|
1918
|
+
// dispose pmremGenerator we created
|
|
1919
|
+
if (!options.pmremGenerator && pmremGenerator) {
|
|
1920
|
+
try {
|
|
1921
|
+
pmremGenerator.dispose();
|
|
1759
1922
|
}
|
|
1923
|
+
catch { }
|
|
1760
1924
|
}
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1925
|
+
}
|
|
1926
|
+
};
|
|
1927
|
+
if (options.cache)
|
|
1928
|
+
cubeCache.set(key, { handle, refCount: 1 });
|
|
1929
|
+
return handle;
|
|
1766
1930
|
}
|
|
1767
1931
|
/**
|
|
1768
|
-
*
|
|
1932
|
+
* Load Equirectangular/Single Image (Supports HDR via RGBELoader)
|
|
1769
1933
|
* @param renderer THREE.WebGLRenderer
|
|
1770
1934
|
* @param scene THREE.Scene
|
|
1771
1935
|
* @param url string - *.hdr, *.exr, *.jpg, *.png
|
|
1772
1936
|
* @param opts SkyboxOptions
|
|
1773
1937
|
*/
|
|
1774
|
-
function loadEquirectSkybox(
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
const
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
//
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1938
|
+
async function loadEquirectSkybox(renderer, scene, url, opts = {}) {
|
|
1939
|
+
const options = { ...DEFAULT_OPTIONS, ...opts };
|
|
1940
|
+
const key = url;
|
|
1941
|
+
if (options.cache && equirectCache.has(key)) {
|
|
1942
|
+
const rec = equirectCache.get(key);
|
|
1943
|
+
rec.refCount += 1;
|
|
1944
|
+
if (options.setAsBackground)
|
|
1945
|
+
scene.background = rec.handle.backgroundTexture;
|
|
1946
|
+
if (options.setAsEnvironment && rec.handle.envRenderTarget)
|
|
1947
|
+
scene.environment = rec.handle.envRenderTarget.texture;
|
|
1948
|
+
return rec.handle;
|
|
1949
|
+
}
|
|
1950
|
+
// Dynamically import RGBELoader (for .hdr/.exr), if loading normal jpg/png directly use TextureLoader
|
|
1951
|
+
const isHDR = /\.hdr$|\.exr$/i.test(url);
|
|
1952
|
+
let hdrTexture;
|
|
1953
|
+
if (isHDR) {
|
|
1954
|
+
const { RGBELoader } = await import('three/examples/jsm/loaders/RGBELoader.js');
|
|
1955
|
+
hdrTexture = await new Promise((resolve, reject) => {
|
|
1956
|
+
new RGBELoader().load(url, (tex) => resolve(tex), undefined, (err) => reject(err));
|
|
1957
|
+
});
|
|
1958
|
+
// RGBE textures typically use LinearEncoding
|
|
1959
|
+
hdrTexture.encoding = THREE.LinearEncoding;
|
|
1960
|
+
}
|
|
1961
|
+
else {
|
|
1962
|
+
// ordinary image - use TextureLoader
|
|
1963
|
+
const loader = new THREE.TextureLoader();
|
|
1964
|
+
hdrTexture = await new Promise((resolve, reject) => {
|
|
1965
|
+
loader.load(url, (t) => resolve(t), undefined, (err) => reject(err));
|
|
1966
|
+
});
|
|
1967
|
+
if (options.useSRGBEncoding)
|
|
1968
|
+
hdrTexture.encoding = THREE.sRGBEncoding;
|
|
1969
|
+
}
|
|
1970
|
+
// PMREMGenerator to convert equirectangular to prefiltered cubemap (good for PBR)
|
|
1971
|
+
const pmremGenerator = options.pmremGenerator ?? new THREE.PMREMGenerator(renderer);
|
|
1972
|
+
pmremGenerator.compileEquirectangularShader?.();
|
|
1973
|
+
const envRenderTarget = pmremGenerator.fromEquirectangular(hdrTexture);
|
|
1974
|
+
// envTexture to use for scene.environment
|
|
1975
|
+
const envTexture = envRenderTarget.texture;
|
|
1976
|
+
// set background and/or environment
|
|
1977
|
+
if (options.setAsBackground) {
|
|
1978
|
+
// for background it's ok to use the equirect texture directly or the envTexture
|
|
1979
|
+
// envTexture is cubemap-like and usually better for reflections; using it as background creates cube-projected look
|
|
1980
|
+
scene.background = envTexture;
|
|
1981
|
+
}
|
|
1982
|
+
if (options.setAsEnvironment) {
|
|
1983
|
+
scene.environment = envTexture;
|
|
1984
|
+
}
|
|
1985
|
+
// We can dispose the original hdrTexture (the PMREM target contains the needed data)
|
|
1986
|
+
try {
|
|
1987
|
+
hdrTexture.dispose();
|
|
1988
|
+
}
|
|
1989
|
+
catch { }
|
|
1990
|
+
const handle = {
|
|
1991
|
+
key,
|
|
1992
|
+
backgroundTexture: options.setAsBackground ? envTexture : null,
|
|
1993
|
+
envRenderTarget,
|
|
1994
|
+
pmremGenerator: options.pmremGenerator ? null : pmremGenerator,
|
|
1995
|
+
setAsBackground: !!options.setAsBackground,
|
|
1996
|
+
setAsEnvironment: !!options.setAsEnvironment,
|
|
1997
|
+
dispose() {
|
|
1998
|
+
if (options.setAsBackground && scene.background === envTexture)
|
|
1999
|
+
scene.background = null;
|
|
2000
|
+
if (options.setAsEnvironment && scene.environment === envTexture)
|
|
2001
|
+
scene.environment = null;
|
|
2002
|
+
try {
|
|
2003
|
+
envRenderTarget.dispose();
|
|
2004
|
+
}
|
|
2005
|
+
catch { }
|
|
2006
|
+
if (!options.pmremGenerator && pmremGenerator) {
|
|
1840
2007
|
try {
|
|
1841
|
-
|
|
1842
|
-
}
|
|
1843
|
-
catch (_a) { }
|
|
1844
|
-
if (!options.pmremGenerator && pmremGenerator) {
|
|
1845
|
-
try {
|
|
1846
|
-
pmremGenerator.dispose();
|
|
1847
|
-
}
|
|
1848
|
-
catch (_b) { }
|
|
2008
|
+
pmremGenerator.dispose();
|
|
1849
2009
|
}
|
|
2010
|
+
catch { }
|
|
1850
2011
|
}
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
if (options.cache)
|
|
2015
|
+
equirectCache.set(key, { handle, refCount: 1 });
|
|
2016
|
+
return handle;
|
|
1856
2017
|
}
|
|
1857
|
-
function loadSkybox(
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
return loadEquirectSkybox(renderer, scene, params.url, opts);
|
|
1862
|
-
});
|
|
2018
|
+
async function loadSkybox(renderer, scene, params, opts = {}) {
|
|
2019
|
+
if (params.type === 'cube')
|
|
2020
|
+
return loadCubeSkybox(renderer, scene, params.paths, opts);
|
|
2021
|
+
return loadEquirectSkybox(renderer, scene, params.url, opts);
|
|
1863
2022
|
}
|
|
1864
2023
|
/* -------------------------
|
|
1865
|
-
|
|
2024
|
+
Cache / Reference Counting Helper Methods
|
|
1866
2025
|
------------------------- */
|
|
1867
|
-
/**
|
|
2026
|
+
/** Release a cached skybox (decrements refCount, only truly disposes when refCount=0) */
|
|
1868
2027
|
function releaseSkybox(handle) {
|
|
1869
2028
|
// check cube cache
|
|
1870
2029
|
if (cubeCache.has(handle.key)) {
|
|
@@ -1889,85 +2048,94 @@ function releaseSkybox(handle) {
|
|
|
1889
2048
|
// handle.dispose()
|
|
1890
2049
|
}
|
|
1891
2050
|
|
|
1892
|
-
// utils/BlueSkyManager.ts - 优化版
|
|
1893
2051
|
/**
|
|
1894
|
-
*
|
|
2052
|
+
* @file blueSkyManager.ts
|
|
2053
|
+
* @description
|
|
2054
|
+
* Global singleton manager for loading and managing HDR/EXR blue sky environment maps.
|
|
2055
|
+
*
|
|
2056
|
+
* @best-practice
|
|
2057
|
+
* - Call `init` once before use.
|
|
2058
|
+
* - Use `loadAsync` to load skyboxes with progress tracking.
|
|
2059
|
+
* - Automatically handles PMREM generation for realistic lighting.
|
|
2060
|
+
*/
|
|
2061
|
+
/**
|
|
2062
|
+
* BlueSkyManager - Optimized
|
|
1895
2063
|
* ---------------------------------------------------------
|
|
1896
|
-
*
|
|
2064
|
+
* A global singleton manager for loading and managing HDR/EXR based blue sky environment maps.
|
|
1897
2065
|
*
|
|
1898
|
-
*
|
|
1899
|
-
* -
|
|
1900
|
-
* -
|
|
1901
|
-
* -
|
|
1902
|
-
* -
|
|
1903
|
-
* -
|
|
2066
|
+
* Features:
|
|
2067
|
+
* - Adds load progress callback
|
|
2068
|
+
* - Supports load cancellation
|
|
2069
|
+
* - Improved error handling
|
|
2070
|
+
* - Returns Promise for async operation
|
|
2071
|
+
* - Adds loading state management
|
|
1904
2072
|
*/
|
|
1905
2073
|
class BlueSkyManager {
|
|
1906
2074
|
constructor() {
|
|
1907
|
-
/**
|
|
2075
|
+
/** RenderTarget for current environment map, used for subsequent disposal */
|
|
1908
2076
|
this.skyRT = null;
|
|
1909
|
-
/**
|
|
2077
|
+
/** Whether already initialized */
|
|
1910
2078
|
this.isInitialized = false;
|
|
1911
|
-
/**
|
|
2079
|
+
/** Current loader, used for cancelling load */
|
|
1912
2080
|
this.currentLoader = null;
|
|
1913
|
-
/**
|
|
2081
|
+
/** Loading state */
|
|
1914
2082
|
this.loadingState = 'idle';
|
|
1915
2083
|
}
|
|
1916
2084
|
/**
|
|
1917
|
-
*
|
|
2085
|
+
* Initialize
|
|
1918
2086
|
* ---------------------------------------------------------
|
|
1919
|
-
*
|
|
1920
|
-
* @param renderer WebGLRenderer
|
|
1921
|
-
* @param scene Three.js
|
|
1922
|
-
* @param exposure
|
|
2087
|
+
* Must be called once before using BlueSkyManager.
|
|
2088
|
+
* @param renderer WebGLRenderer instance
|
|
2089
|
+
* @param scene Three.js Scene
|
|
2090
|
+
* @param exposure Exposure (default 1.0)
|
|
1923
2091
|
*/
|
|
1924
2092
|
init(renderer, scene, exposure = 1.0) {
|
|
1925
2093
|
if (this.isInitialized) {
|
|
1926
|
-
console.warn('BlueSkyManager:
|
|
2094
|
+
console.warn('BlueSkyManager: Already initialized, skipping duplicate initialization');
|
|
1927
2095
|
return;
|
|
1928
2096
|
}
|
|
1929
2097
|
this.renderer = renderer;
|
|
1930
2098
|
this.scene = scene;
|
|
1931
|
-
//
|
|
2099
|
+
// Use ACESFilmicToneMapping, effect is closer to reality
|
|
1932
2100
|
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
1933
2101
|
this.renderer.toneMappingExposure = exposure;
|
|
1934
|
-
//
|
|
2102
|
+
// Initialize PMREM generator (only one needed globally)
|
|
1935
2103
|
this.pmremGen = new THREE.PMREMGenerator(renderer);
|
|
1936
2104
|
this.pmremGen.compileEquirectangularShader();
|
|
1937
2105
|
this.isInitialized = true;
|
|
1938
2106
|
}
|
|
1939
2107
|
/**
|
|
1940
|
-
*
|
|
2108
|
+
* Load blue sky HDR/EXR map and apply to scene (Promise version)
|
|
1941
2109
|
* ---------------------------------------------------------
|
|
1942
|
-
* @param exrPath HDR/EXR
|
|
1943
|
-
* @param options
|
|
2110
|
+
* @param exrPath HDR/EXR file path
|
|
2111
|
+
* @param options Load options
|
|
1944
2112
|
* @returns Promise<void>
|
|
1945
2113
|
*/
|
|
1946
2114
|
loadAsync(exrPath, options = {}) {
|
|
1947
2115
|
if (!this.isInitialized) {
|
|
1948
2116
|
return Promise.reject(new Error('BlueSkyManager not initialized!'));
|
|
1949
2117
|
}
|
|
1950
|
-
//
|
|
2118
|
+
// Cancel previous load
|
|
1951
2119
|
this.cancelLoad();
|
|
1952
2120
|
const { background = true, onProgress, onComplete, onError } = options;
|
|
1953
2121
|
this.loadingState = 'loading';
|
|
1954
2122
|
this.currentLoader = new EXRLoader();
|
|
1955
2123
|
return new Promise((resolve, reject) => {
|
|
1956
2124
|
this.currentLoader.load(exrPath,
|
|
1957
|
-
//
|
|
2125
|
+
// Success callback
|
|
1958
2126
|
(texture) => {
|
|
1959
2127
|
try {
|
|
1960
|
-
//
|
|
2128
|
+
// Set texture mapping to EquirectangularReflectionMapping
|
|
1961
2129
|
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
1962
|
-
//
|
|
2130
|
+
// Clear old environment map
|
|
1963
2131
|
this.dispose();
|
|
1964
|
-
//
|
|
2132
|
+
// Generate efficient environment map using PMREM
|
|
1965
2133
|
this.skyRT = this.pmremGen.fromEquirectangular(texture);
|
|
1966
|
-
//
|
|
2134
|
+
// Apply to scene: Environment Lighting & Background
|
|
1967
2135
|
this.scene.environment = this.skyRT.texture;
|
|
1968
2136
|
if (background)
|
|
1969
2137
|
this.scene.background = this.skyRT.texture;
|
|
1970
|
-
//
|
|
2138
|
+
// Dispose original HDR/EXR texture immediately to save memory
|
|
1971
2139
|
texture.dispose();
|
|
1972
2140
|
this.loadingState = 'loaded';
|
|
1973
2141
|
this.currentLoader = null;
|
|
@@ -1985,14 +2153,14 @@ class BlueSkyManager {
|
|
|
1985
2153
|
reject(error);
|
|
1986
2154
|
}
|
|
1987
2155
|
},
|
|
1988
|
-
//
|
|
2156
|
+
// Progress callback
|
|
1989
2157
|
(xhr) => {
|
|
1990
2158
|
if (onProgress && xhr.lengthComputable) {
|
|
1991
2159
|
const progress = xhr.loaded / xhr.total;
|
|
1992
2160
|
onProgress(progress);
|
|
1993
2161
|
}
|
|
1994
2162
|
},
|
|
1995
|
-
//
|
|
2163
|
+
// Error callback
|
|
1996
2164
|
(err) => {
|
|
1997
2165
|
this.loadingState = 'error';
|
|
1998
2166
|
this.currentLoader = null;
|
|
@@ -2004,10 +2172,10 @@ class BlueSkyManager {
|
|
|
2004
2172
|
});
|
|
2005
2173
|
}
|
|
2006
2174
|
/**
|
|
2007
|
-
*
|
|
2175
|
+
* Load blue sky HDR/EXR map and apply to scene (Sync API, for backward compatibility)
|
|
2008
2176
|
* ---------------------------------------------------------
|
|
2009
|
-
* @param exrPath HDR/EXR
|
|
2010
|
-
* @param background
|
|
2177
|
+
* @param exrPath HDR/EXR file path
|
|
2178
|
+
* @param background Whether to apply as scene background (default true)
|
|
2011
2179
|
*/
|
|
2012
2180
|
load(exrPath, background = true) {
|
|
2013
2181
|
this.loadAsync(exrPath, { background }).catch((error) => {
|
|
@@ -2015,32 +2183,32 @@ class BlueSkyManager {
|
|
|
2015
2183
|
});
|
|
2016
2184
|
}
|
|
2017
2185
|
/**
|
|
2018
|
-
*
|
|
2186
|
+
* Cancel current load
|
|
2019
2187
|
*/
|
|
2020
2188
|
cancelLoad() {
|
|
2021
2189
|
if (this.currentLoader) {
|
|
2022
|
-
// EXRLoader
|
|
2190
|
+
// EXRLoader itself does not have abort method, but we can clear the reference
|
|
2023
2191
|
this.currentLoader = null;
|
|
2024
2192
|
this.loadingState = 'idle';
|
|
2025
2193
|
}
|
|
2026
2194
|
}
|
|
2027
2195
|
/**
|
|
2028
|
-
*
|
|
2196
|
+
* Get loading state
|
|
2029
2197
|
*/
|
|
2030
2198
|
getLoadingState() {
|
|
2031
2199
|
return this.loadingState;
|
|
2032
2200
|
}
|
|
2033
2201
|
/**
|
|
2034
|
-
*
|
|
2202
|
+
* Is loading
|
|
2035
2203
|
*/
|
|
2036
2204
|
isLoading() {
|
|
2037
2205
|
return this.loadingState === 'loading';
|
|
2038
2206
|
}
|
|
2039
2207
|
/**
|
|
2040
|
-
*
|
|
2208
|
+
* Release current sky texture resources
|
|
2041
2209
|
* ---------------------------------------------------------
|
|
2042
|
-
*
|
|
2043
|
-
*
|
|
2210
|
+
* Only cleans up skyRT, does not destroy PMREM
|
|
2211
|
+
* Suitable for calling when switching HDR/EXR files
|
|
2044
2212
|
*/
|
|
2045
2213
|
dispose() {
|
|
2046
2214
|
if (this.skyRT) {
|
|
@@ -2054,53 +2222,61 @@ class BlueSkyManager {
|
|
|
2054
2222
|
this.scene.environment = null;
|
|
2055
2223
|
}
|
|
2056
2224
|
/**
|
|
2057
|
-
*
|
|
2225
|
+
* Completely destroy BlueSkyManager
|
|
2058
2226
|
* ---------------------------------------------------------
|
|
2059
|
-
*
|
|
2060
|
-
*
|
|
2227
|
+
* Includes destruction of PMREMGenerator
|
|
2228
|
+
* Usually called when the scene is completely destroyed or the application exits
|
|
2061
2229
|
*/
|
|
2062
2230
|
destroy() {
|
|
2063
|
-
var _a;
|
|
2064
2231
|
this.cancelLoad();
|
|
2065
2232
|
this.dispose();
|
|
2066
|
-
|
|
2233
|
+
this.pmremGen?.dispose();
|
|
2067
2234
|
this.isInitialized = false;
|
|
2068
2235
|
this.loadingState = 'idle';
|
|
2069
2236
|
}
|
|
2070
2237
|
}
|
|
2071
2238
|
/**
|
|
2072
|
-
*
|
|
2239
|
+
* Global Singleton
|
|
2073
2240
|
* ---------------------------------------------------------
|
|
2074
|
-
*
|
|
2075
|
-
*
|
|
2241
|
+
* Directly export a globally unique BlueSkyManager instance,
|
|
2242
|
+
* Ensuring only one PMREMGenerator is used throughout the application for best performance.
|
|
2076
2243
|
*/
|
|
2077
2244
|
const BlueSky = new BlueSkyManager();
|
|
2078
2245
|
|
|
2079
2246
|
/**
|
|
2080
|
-
*
|
|
2247
|
+
* @file modelsLabel.ts
|
|
2248
|
+
* @description
|
|
2249
|
+
* Creates interactive 2D labels (DOM elements) attached to 3D objects with connecting lines.
|
|
2250
|
+
*
|
|
2251
|
+
* @best-practice
|
|
2252
|
+
* - Use `createModelsLabel` to annotate parts of a model.
|
|
2253
|
+
* - Supports fading endpoints, pulsing dots, and custom styling.
|
|
2254
|
+
* - Performance optimized with caching and RAF throttling.
|
|
2255
|
+
*/
|
|
2256
|
+
/**
|
|
2257
|
+
* Create Model Labels (with connecting lines and pulsing dots) - Optimized
|
|
2081
2258
|
*
|
|
2082
|
-
*
|
|
2083
|
-
* -
|
|
2084
|
-
* -
|
|
2085
|
-
* -
|
|
2086
|
-
* -
|
|
2087
|
-
* - RAF
|
|
2259
|
+
* Features:
|
|
2260
|
+
* - Supports pause/resume
|
|
2261
|
+
* - Configurable update interval
|
|
2262
|
+
* - Fade in/out effects
|
|
2263
|
+
* - Cached bounding box calculation
|
|
2264
|
+
* - RAF management optimization
|
|
2088
2265
|
*/
|
|
2089
2266
|
function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, options) {
|
|
2090
|
-
var _a, _b, _c, _d, _e, _f;
|
|
2091
2267
|
const cfg = {
|
|
2092
|
-
fontSize:
|
|
2093
|
-
color:
|
|
2094
|
-
background:
|
|
2095
|
-
padding:
|
|
2096
|
-
borderRadius:
|
|
2097
|
-
lift:
|
|
2098
|
-
dotSize:
|
|
2099
|
-
dotSpacing:
|
|
2100
|
-
lineColor:
|
|
2101
|
-
lineWidth:
|
|
2102
|
-
updateInterval:
|
|
2103
|
-
fadeInDuration:
|
|
2268
|
+
fontSize: options?.fontSize || '12px',
|
|
2269
|
+
color: options?.color || '#ffffff',
|
|
2270
|
+
background: options?.background || '#1890ff',
|
|
2271
|
+
padding: options?.padding || '6px 10px',
|
|
2272
|
+
borderRadius: options?.borderRadius || '6px',
|
|
2273
|
+
lift: options?.lift ?? 100,
|
|
2274
|
+
dotSize: options?.dotSize ?? 6,
|
|
2275
|
+
dotSpacing: options?.dotSpacing ?? 2,
|
|
2276
|
+
lineColor: options?.lineColor || 'rgba(200,200,200,0.7)',
|
|
2277
|
+
lineWidth: options?.lineWidth ?? 1,
|
|
2278
|
+
updateInterval: options?.updateInterval ?? 0, // Default update every frame
|
|
2279
|
+
fadeInDuration: options?.fadeInDuration ?? 300, // Fade-in duration
|
|
2104
2280
|
};
|
|
2105
2281
|
const container = document.createElement('div');
|
|
2106
2282
|
container.style.position = 'absolute';
|
|
@@ -2123,13 +2299,13 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2123
2299
|
svg.style.zIndex = '1';
|
|
2124
2300
|
container.appendChild(svg);
|
|
2125
2301
|
let currentModel = parentModel;
|
|
2126
|
-
let currentLabelsMap =
|
|
2302
|
+
let currentLabelsMap = { ...modelLabelsMap };
|
|
2127
2303
|
let labels = [];
|
|
2128
2304
|
let isActive = true;
|
|
2129
|
-
let isPaused = false;
|
|
2130
|
-
let rafId = null;
|
|
2131
|
-
let lastUpdateTime = 0;
|
|
2132
|
-
//
|
|
2305
|
+
let isPaused = false;
|
|
2306
|
+
let rafId = null;
|
|
2307
|
+
let lastUpdateTime = 0;
|
|
2308
|
+
// Inject styles (with fade-in animation)
|
|
2133
2309
|
const styleId = 'three-model-label-styles';
|
|
2134
2310
|
if (!document.getElementById(styleId)) {
|
|
2135
2311
|
const style = document.createElement('style');
|
|
@@ -2168,14 +2344,14 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2168
2344
|
`;
|
|
2169
2345
|
document.head.appendChild(style);
|
|
2170
2346
|
}
|
|
2171
|
-
//
|
|
2347
|
+
// Get or update cached top position
|
|
2172
2348
|
const getObjectTopPosition = (labelData) => {
|
|
2173
2349
|
const obj = labelData.object;
|
|
2174
|
-
//
|
|
2350
|
+
// If cached and object hasn't transformed, return cached
|
|
2175
2351
|
if (labelData.cachedTopPos && !obj.matrixWorldNeedsUpdate) {
|
|
2176
2352
|
return labelData.cachedTopPos.clone();
|
|
2177
2353
|
}
|
|
2178
|
-
//
|
|
2354
|
+
// Recalculate
|
|
2179
2355
|
const box = new THREE.Box3().setFromObject(obj);
|
|
2180
2356
|
labelData.cachedBox = box;
|
|
2181
2357
|
if (!box.isEmpty()) {
|
|
@@ -2204,9 +2380,8 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2204
2380
|
if (!currentModel)
|
|
2205
2381
|
return;
|
|
2206
2382
|
currentModel.traverse((child) => {
|
|
2207
|
-
var _a;
|
|
2208
2383
|
if (child.isMesh || child.type === 'Group') {
|
|
2209
|
-
const labelText =
|
|
2384
|
+
const labelText = Object.entries(currentLabelsMap).find(([key]) => child.name.includes(key))?.[1];
|
|
2210
2385
|
if (!labelText)
|
|
2211
2386
|
return;
|
|
2212
2387
|
const wrapper = document.createElement('div');
|
|
@@ -2253,20 +2428,20 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2253
2428
|
wrapper,
|
|
2254
2429
|
dot,
|
|
2255
2430
|
line,
|
|
2256
|
-
cachedBox: null, //
|
|
2431
|
+
cachedBox: null, // Initialize cache
|
|
2257
2432
|
cachedTopPos: null
|
|
2258
2433
|
});
|
|
2259
2434
|
}
|
|
2260
2435
|
});
|
|
2261
2436
|
};
|
|
2262
2437
|
rebuildLabels();
|
|
2263
|
-
//
|
|
2438
|
+
// Optimized update function
|
|
2264
2439
|
const updateLabels = (timestamp) => {
|
|
2265
2440
|
if (!isActive || isPaused) {
|
|
2266
2441
|
rafId = null;
|
|
2267
2442
|
return;
|
|
2268
2443
|
}
|
|
2269
|
-
//
|
|
2444
|
+
// Throttle
|
|
2270
2445
|
if (cfg.updateInterval > 0 && timestamp - lastUpdateTime < cfg.updateInterval) {
|
|
2271
2446
|
rafId = requestAnimationFrame(updateLabels);
|
|
2272
2447
|
return;
|
|
@@ -2279,7 +2454,7 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2279
2454
|
svg.setAttribute('height', `${height}`);
|
|
2280
2455
|
labels.forEach((labelData) => {
|
|
2281
2456
|
const { el, wrapper, dot, line } = labelData;
|
|
2282
|
-
const topWorld = getObjectTopPosition(labelData); //
|
|
2457
|
+
const topWorld = getObjectTopPosition(labelData); // Use cache
|
|
2283
2458
|
const topNDC = topWorld.clone().project(camera);
|
|
2284
2459
|
const modelX = (topNDC.x * 0.5 + 0.5) * width + rect.left;
|
|
2285
2460
|
const modelY = (-(topNDC.y * 0.5) + 0.5) * height + rect.top;
|
|
@@ -2311,10 +2486,10 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2311
2486
|
rebuildLabels();
|
|
2312
2487
|
},
|
|
2313
2488
|
updateLabelsMap(newMap) {
|
|
2314
|
-
currentLabelsMap =
|
|
2489
|
+
currentLabelsMap = { ...newMap };
|
|
2315
2490
|
rebuildLabels();
|
|
2316
2491
|
},
|
|
2317
|
-
//
|
|
2492
|
+
// Pause update
|
|
2318
2493
|
pause() {
|
|
2319
2494
|
isPaused = true;
|
|
2320
2495
|
if (rafId !== null) {
|
|
@@ -2322,7 +2497,7 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2322
2497
|
rafId = null;
|
|
2323
2498
|
}
|
|
2324
2499
|
},
|
|
2325
|
-
//
|
|
2500
|
+
// Resume update
|
|
2326
2501
|
resume() {
|
|
2327
2502
|
if (!isPaused)
|
|
2328
2503
|
return;
|
|
@@ -2343,10 +2518,34 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2343
2518
|
};
|
|
2344
2519
|
}
|
|
2345
2520
|
|
|
2521
|
+
/**
|
|
2522
|
+
* @file exploder.ts
|
|
2523
|
+
* @description
|
|
2524
|
+
* GroupExploder - Three.js based model explosion effect tool (Vue3 + TS Support)
|
|
2525
|
+
* ----------------------------------------------------------------------
|
|
2526
|
+
* This tool is used to perform "explode / restore" animations on a set of specified Meshes:
|
|
2527
|
+
* - Initialize only once (onMounted)
|
|
2528
|
+
* - Supports dynamic switching of models and automatically restores the explosion state of the previous model
|
|
2529
|
+
* - Supports multiple arrangement modes (ring / spiral / grid / radial)
|
|
2530
|
+
* - Supports automatic transparency for non-exploded objects (dimOthers)
|
|
2531
|
+
* - Supports automatic camera positioning to the best observation point
|
|
2532
|
+
* - All animations use native requestAnimationFrame
|
|
2533
|
+
*
|
|
2534
|
+
* @best-practice
|
|
2535
|
+
* - Initialize in `onMounted`.
|
|
2536
|
+
* - Use `setMeshes` to update the active set of meshes to explode.
|
|
2537
|
+
* - Call `explode()` to trigger the effect and `restore()` to reset.
|
|
2538
|
+
*/
|
|
2346
2539
|
function easeInOutQuad(t) {
|
|
2347
2540
|
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
2348
2541
|
}
|
|
2349
2542
|
class GroupExploder {
|
|
2543
|
+
/**
|
|
2544
|
+
* Constructor
|
|
2545
|
+
* @param scene Three.js Scene instance
|
|
2546
|
+
* @param camera Three.js Camera (usually PerspectiveCamera)
|
|
2547
|
+
* @param controls OrbitControls instance (must be bound to camera)
|
|
2548
|
+
*/
|
|
2350
2549
|
constructor(scene, camera, controls) {
|
|
2351
2550
|
// sets and snapshots
|
|
2352
2551
|
this.currentSet = null;
|
|
@@ -2378,211 +2577,210 @@ class GroupExploder {
|
|
|
2378
2577
|
this.log('init() called');
|
|
2379
2578
|
}
|
|
2380
2579
|
/**
|
|
2381
|
-
*
|
|
2580
|
+
* Set the current set of meshes for explosion.
|
|
2382
2581
|
* - Detects content-level changes even if same Set reference is used.
|
|
2383
2582
|
* - Preserves prevSet/stateMap to allow async restore when needed.
|
|
2384
2583
|
* - Ensures stateMap contains snapshots for *all meshes in the new set*.
|
|
2584
|
+
* @param newSet The new set of meshes
|
|
2585
|
+
* @param contextId Optional context ID to distinguish business scenarios
|
|
2385
2586
|
*/
|
|
2386
|
-
setMeshes(newSet, options) {
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2587
|
+
async setMeshes(newSet, options) {
|
|
2588
|
+
const autoRestorePrev = options?.autoRestorePrev ?? true;
|
|
2589
|
+
const restoreDuration = options?.restoreDuration ?? 300;
|
|
2590
|
+
this.log(`setMeshes called. newSetSize=${newSet ? newSet.size : 0}, autoRestorePrev=${autoRestorePrev}`);
|
|
2591
|
+
// If the newSet is null and currentSet is null -> nothing
|
|
2592
|
+
if (!newSet && !this.currentSet) {
|
|
2593
|
+
this.log('setMeshes: both newSet and currentSet are null, nothing to do');
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
// If both exist and are the same reference, we still must detect content changes.
|
|
2597
|
+
const sameReference = this.currentSet === newSet;
|
|
2598
|
+
// Prepare prevSet snapshot (we copy current to prev)
|
|
2599
|
+
if (this.currentSet) {
|
|
2600
|
+
this.prevSet = this.currentSet;
|
|
2601
|
+
this.prevStateMap = new Map(this.stateMap);
|
|
2602
|
+
this.log(`setMeshes: backed up current->prev prevSetSize=${this.prevSet.size}`);
|
|
2603
|
+
}
|
|
2604
|
+
else {
|
|
2605
|
+
this.prevSet = null;
|
|
2606
|
+
this.prevStateMap = new Map();
|
|
2607
|
+
}
|
|
2608
|
+
// If we used to be exploded and need to restore prevSet, do that first (await)
|
|
2609
|
+
if (this.prevSet && autoRestorePrev && this.isExploded) {
|
|
2610
|
+
this.log('setMeshes: need to restore prevSet before applying newSet');
|
|
2611
|
+
await this.restoreSet(this.prevSet, this.prevStateMap, restoreDuration, { debug: true });
|
|
2612
|
+
this.log('setMeshes: prevSet restore done');
|
|
2613
|
+
this.prevStateMap.clear();
|
|
2614
|
+
this.prevSet = null;
|
|
2615
|
+
}
|
|
2616
|
+
// Now register newSet: we clear and rebuild stateMap carefully.
|
|
2617
|
+
// But we must handle the case where caller reuses same Set object and just mutated elements.
|
|
2618
|
+
// We will compute additions and removals.
|
|
2619
|
+
const oldSet = this.currentSet;
|
|
2620
|
+
this.currentSet = newSet;
|
|
2621
|
+
// If newSet is null -> simply clear stateMap
|
|
2622
|
+
if (!this.currentSet) {
|
|
2623
|
+
this.stateMap.clear();
|
|
2624
|
+
this.log('setMeshes: newSet is null -> cleared stateMap');
|
|
2625
|
+
this.isExploded = false;
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
// If we have oldSet (could be same reference) then compute diffs
|
|
2629
|
+
if (oldSet) {
|
|
2630
|
+
// If same reference but size or content differs -> handle diffs
|
|
2631
|
+
const wasSameRef = sameReference;
|
|
2632
|
+
let added = [];
|
|
2633
|
+
let removed = [];
|
|
2634
|
+
// Build maps of membership
|
|
2635
|
+
const oldMembers = new Set(Array.from(oldSet));
|
|
2636
|
+
const newMembers = new Set(Array.from(this.currentSet));
|
|
2637
|
+
// find removals
|
|
2638
|
+
oldMembers.forEach((m) => {
|
|
2639
|
+
if (!newMembers.has(m))
|
|
2640
|
+
removed.push(m);
|
|
2641
|
+
});
|
|
2642
|
+
// find additions
|
|
2643
|
+
newMembers.forEach((m) => {
|
|
2644
|
+
if (!oldMembers.has(m))
|
|
2645
|
+
added.push(m);
|
|
2646
|
+
});
|
|
2647
|
+
if (wasSameRef && added.length === 0 && removed.length === 0) {
|
|
2648
|
+
// truly identical (no content changes)
|
|
2649
|
+
this.log('setMeshes: same reference and identical contents -> nothing to update');
|
|
2427
2650
|
return;
|
|
2428
2651
|
}
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
let removed = [];
|
|
2435
|
-
// Build maps of membership
|
|
2436
|
-
const oldMembers = new Set(Array.from(oldSet));
|
|
2437
|
-
const newMembers = new Set(Array.from(this.currentSet));
|
|
2438
|
-
// find removals
|
|
2439
|
-
oldMembers.forEach((m) => {
|
|
2440
|
-
if (!newMembers.has(m))
|
|
2441
|
-
removed.push(m);
|
|
2442
|
-
});
|
|
2443
|
-
// find additions
|
|
2444
|
-
newMembers.forEach((m) => {
|
|
2445
|
-
if (!oldMembers.has(m))
|
|
2446
|
-
added.push(m);
|
|
2447
|
-
});
|
|
2448
|
-
if (wasSameRef && added.length === 0 && removed.length === 0) {
|
|
2449
|
-
// truly identical (no content changes)
|
|
2450
|
-
this.log('setMeshes: same reference and identical contents -> nothing to update');
|
|
2451
|
-
return;
|
|
2652
|
+
this.log(`setMeshes: diff detected -> added=${added.length}, removed=${removed.length}`);
|
|
2653
|
+
// Remove snapshots for removed meshes
|
|
2654
|
+
removed.forEach((m) => {
|
|
2655
|
+
if (this.stateMap.has(m)) {
|
|
2656
|
+
this.stateMap.delete(m);
|
|
2452
2657
|
}
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
this.stateMap.clear();
|
|
2469
|
-
yield this.ensureSnapshotsForSet(this.currentSet);
|
|
2470
|
-
this.log(`setMeshes: recorded stateMap entries for newSet size=${this.stateMap.size}`);
|
|
2471
|
-
this.isExploded = false;
|
|
2472
|
-
return;
|
|
2473
|
-
}
|
|
2474
|
-
});
|
|
2658
|
+
});
|
|
2659
|
+
// Ensure snapshots exist for current set members (create for newly added meshes)
|
|
2660
|
+
await this.ensureSnapshotsForSet(this.currentSet);
|
|
2661
|
+
this.log(`setMeshes: after diff handling, stateMap size=${this.stateMap.size}`);
|
|
2662
|
+
this.isExploded = false;
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
else {
|
|
2666
|
+
// no oldSet -> brand new registration
|
|
2667
|
+
this.stateMap.clear();
|
|
2668
|
+
await this.ensureSnapshotsForSet(this.currentSet);
|
|
2669
|
+
this.log(`setMeshes: recorded stateMap entries for newSet size=${this.stateMap.size}`);
|
|
2670
|
+
this.isExploded = false;
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2475
2673
|
}
|
|
2476
2674
|
/**
|
|
2477
2675
|
* ensureSnapshotsForSet: for each mesh in set, ensure stateMap has an entry.
|
|
2478
2676
|
* If missing, record current matrixWorld as originalMatrixWorld (best-effort).
|
|
2479
2677
|
*/
|
|
2480
|
-
ensureSnapshotsForSet(set) {
|
|
2481
|
-
|
|
2482
|
-
|
|
2678
|
+
async ensureSnapshotsForSet(set) {
|
|
2679
|
+
set.forEach((m) => {
|
|
2680
|
+
try {
|
|
2681
|
+
m.updateMatrixWorld(true);
|
|
2682
|
+
}
|
|
2683
|
+
catch { }
|
|
2684
|
+
if (!this.stateMap.has(m)) {
|
|
2483
2685
|
try {
|
|
2484
|
-
|
|
2686
|
+
this.stateMap.set(m, {
|
|
2687
|
+
originalParent: m.parent || null,
|
|
2688
|
+
originalMatrixWorld: (m.matrixWorld && m.matrixWorld.clone()) || new THREE.Matrix4().copy(m.matrix),
|
|
2689
|
+
});
|
|
2690
|
+
// Also store in userData for extra resilience
|
|
2691
|
+
m.userData.__originalMatrixWorld = this.stateMap.get(m).originalMatrixWorld.clone();
|
|
2485
2692
|
}
|
|
2486
|
-
catch (
|
|
2487
|
-
|
|
2488
|
-
try {
|
|
2489
|
-
this.stateMap.set(m, {
|
|
2490
|
-
originalParent: m.parent || null,
|
|
2491
|
-
originalMatrixWorld: (m.matrixWorld && m.matrixWorld.clone()) || new THREE.Matrix4().copy(m.matrix),
|
|
2492
|
-
});
|
|
2493
|
-
// Also store in userData for extra resilience
|
|
2494
|
-
m.userData.__originalMatrixWorld = this.stateMap.get(m).originalMatrixWorld.clone();
|
|
2495
|
-
}
|
|
2496
|
-
catch (e) {
|
|
2497
|
-
this.log(`ensureSnapshotsForSet: failed to snapshot mesh ${m.name || m.id}: ${e.message}`);
|
|
2498
|
-
}
|
|
2693
|
+
catch (e) {
|
|
2694
|
+
this.log(`ensureSnapshotsForSet: failed to snapshot mesh ${m.name || m.id}: ${e.message}`);
|
|
2499
2695
|
}
|
|
2500
|
-
}
|
|
2696
|
+
}
|
|
2501
2697
|
});
|
|
2502
2698
|
}
|
|
2503
2699
|
/**
|
|
2504
2700
|
* explode: compute targets first, compute targetBound using targets + mesh radii,
|
|
2505
2701
|
* animate camera to that targetBound, then animate meshes to targets.
|
|
2506
2702
|
*/
|
|
2507
|
-
explode(opts) {
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2703
|
+
async explode(opts) {
|
|
2704
|
+
if (!this.currentSet || this.currentSet.size === 0) {
|
|
2705
|
+
this.log('explode: empty currentSet, nothing to do');
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
const { spacing = 2, duration = 1000, lift = 0.5, cameraPadding = 1.5, mode = 'spiral', dimOthers = { enabled: true, opacity: 0.25 }, debug = false, } = opts || {};
|
|
2709
|
+
this.log(`explode called. setSize=${this.currentSet.size}, mode=${mode}, spacing=${spacing}, duration=${duration}, lift=${lift}, dim=${dimOthers.enabled}`);
|
|
2710
|
+
this.cancelAnimations();
|
|
2711
|
+
const meshes = Array.from(this.currentSet);
|
|
2712
|
+
// ensure snapshots exist for any meshes that may have been added after initial registration
|
|
2713
|
+
await this.ensureSnapshotsForSet(this.currentSet);
|
|
2714
|
+
// compute center/radius from current meshes (fallback)
|
|
2715
|
+
const initial = this.computeBoundingSphereForMeshes(meshes);
|
|
2716
|
+
const center = initial.center;
|
|
2717
|
+
const baseRadius = Math.max(1, initial.radius);
|
|
2718
|
+
this.log(`explode: initial center=${center.toArray().map((n) => n.toFixed(3))}, baseRadius=${baseRadius.toFixed(3)}`);
|
|
2719
|
+
// compute targets (pure calculation)
|
|
2720
|
+
const targets = this.computeTargetsByMode(meshes, center, baseRadius + spacing, { lift, mode });
|
|
2721
|
+
this.log(`explode: computed ${targets.length} target positions`);
|
|
2722
|
+
// compute target-based bounding sphere (targets + per-mesh radius)
|
|
2723
|
+
const targetBound = this.computeBoundingSphereForPositionsAndMeshes(targets, meshes);
|
|
2724
|
+
this.log(`explode: targetBound center=${targetBound.center.toArray().map((n) => n.toFixed(3))}, radius=${targetBound.radius.toFixed(3)}`);
|
|
2725
|
+
await this.animateCameraToFit(targetBound.center, targetBound.radius, { duration: Math.min(600, duration), padding: cameraPadding });
|
|
2726
|
+
this.log('explode: camera animation to target bound completed');
|
|
2727
|
+
// apply dim if needed with context id
|
|
2728
|
+
const contextId = dimOthers?.enabled ? this.applyDimToOthers(meshes, dimOthers.opacity ?? 0.25, { debug }) : null;
|
|
2729
|
+
if (contextId)
|
|
2730
|
+
this.log(`explode: applied dim for context ${contextId}`);
|
|
2731
|
+
// capture starts after camera move
|
|
2732
|
+
const starts = meshes.map((m) => {
|
|
2733
|
+
const v = new THREE.Vector3();
|
|
2734
|
+
try {
|
|
2735
|
+
m.getWorldPosition(v);
|
|
2513
2736
|
}
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
const
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
this.log(`explode: applied dim for context ${contextId}`);
|
|
2537
|
-
// capture starts after camera move
|
|
2538
|
-
const starts = meshes.map((m) => {
|
|
2539
|
-
const v = new THREE.Vector3();
|
|
2540
|
-
try {
|
|
2541
|
-
m.getWorldPosition(v);
|
|
2542
|
-
}
|
|
2543
|
-
catch (_a) {
|
|
2544
|
-
// fallback to originalMatrixWorld if available
|
|
2545
|
-
const st = this.stateMap.get(m);
|
|
2546
|
-
if (st)
|
|
2547
|
-
v.setFromMatrixPosition(st.originalMatrixWorld);
|
|
2548
|
-
}
|
|
2549
|
-
return v;
|
|
2550
|
-
});
|
|
2551
|
-
const startTime = performance.now();
|
|
2552
|
-
const total = Math.max(1, duration);
|
|
2553
|
-
const tick = (now) => {
|
|
2554
|
-
const t = Math.min(1, (now - startTime) / total);
|
|
2555
|
-
const eased = easeInOutQuad(t);
|
|
2556
|
-
for (let i = 0; i < meshes.length; i++) {
|
|
2557
|
-
const m = meshes[i];
|
|
2558
|
-
const s = starts[i];
|
|
2559
|
-
const tar = targets[i];
|
|
2560
|
-
const cur = s.clone().lerp(tar, eased);
|
|
2561
|
-
if (m.parent) {
|
|
2562
|
-
const local = cur.clone();
|
|
2563
|
-
m.parent.worldToLocal(local);
|
|
2564
|
-
m.position.copy(local);
|
|
2565
|
-
}
|
|
2566
|
-
else {
|
|
2567
|
-
m.position.copy(cur);
|
|
2568
|
-
}
|
|
2569
|
-
m.updateMatrix();
|
|
2570
|
-
}
|
|
2571
|
-
if (this.controls && typeof this.controls.update === 'function')
|
|
2572
|
-
this.controls.update();
|
|
2573
|
-
if (t < 1) {
|
|
2574
|
-
this.animId = requestAnimationFrame(tick);
|
|
2737
|
+
catch {
|
|
2738
|
+
// fallback to originalMatrixWorld if available
|
|
2739
|
+
const st = this.stateMap.get(m);
|
|
2740
|
+
if (st)
|
|
2741
|
+
v.setFromMatrixPosition(st.originalMatrixWorld);
|
|
2742
|
+
}
|
|
2743
|
+
return v;
|
|
2744
|
+
});
|
|
2745
|
+
const startTime = performance.now();
|
|
2746
|
+
const total = Math.max(1, duration);
|
|
2747
|
+
const tick = (now) => {
|
|
2748
|
+
const t = Math.min(1, (now - startTime) / total);
|
|
2749
|
+
const eased = easeInOutQuad(t);
|
|
2750
|
+
for (let i = 0; i < meshes.length; i++) {
|
|
2751
|
+
const m = meshes[i];
|
|
2752
|
+
const s = starts[i];
|
|
2753
|
+
const tar = targets[i];
|
|
2754
|
+
const cur = s.clone().lerp(tar, eased);
|
|
2755
|
+
if (m.parent) {
|
|
2756
|
+
const local = cur.clone();
|
|
2757
|
+
m.parent.worldToLocal(local);
|
|
2758
|
+
m.position.copy(local);
|
|
2575
2759
|
}
|
|
2576
2760
|
else {
|
|
2577
|
-
|
|
2578
|
-
this.isExploded = true;
|
|
2579
|
-
this.log(`explode: completed. contextId=${contextId !== null && contextId !== void 0 ? contextId : 'none'}`);
|
|
2761
|
+
m.position.copy(cur);
|
|
2580
2762
|
}
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2763
|
+
m.updateMatrix();
|
|
2764
|
+
}
|
|
2765
|
+
if (this.controls && typeof this.controls.update === 'function')
|
|
2766
|
+
this.controls.update();
|
|
2767
|
+
if (t < 1) {
|
|
2768
|
+
this.animId = requestAnimationFrame(tick);
|
|
2769
|
+
}
|
|
2770
|
+
else {
|
|
2771
|
+
this.animId = null;
|
|
2772
|
+
this.isExploded = true;
|
|
2773
|
+
this.log(`explode: completed. contextId=${contextId ?? 'none'}`);
|
|
2774
|
+
}
|
|
2775
|
+
};
|
|
2776
|
+
this.animId = requestAnimationFrame(tick);
|
|
2777
|
+
return;
|
|
2585
2778
|
}
|
|
2779
|
+
/**
|
|
2780
|
+
* Restore all exploded meshes to their original transform:
|
|
2781
|
+
* - Supports smooth animation
|
|
2782
|
+
* - Automatically cancels transparency
|
|
2783
|
+
*/
|
|
2586
2784
|
restore(duration = 400) {
|
|
2587
2785
|
if (!this.currentSet || this.currentSet.size === 0) {
|
|
2588
2786
|
this.log('restore: no currentSet to restore');
|
|
@@ -2599,7 +2797,7 @@ class GroupExploder {
|
|
|
2599
2797
|
*/
|
|
2600
2798
|
restoreSet(set, stateMap, duration = 400, opts) {
|
|
2601
2799
|
if (!set || set.size === 0) {
|
|
2602
|
-
if (opts
|
|
2800
|
+
if (opts?.debug)
|
|
2603
2801
|
this.log('restoreSet: empty set, nothing to restore');
|
|
2604
2802
|
return Promise.resolve();
|
|
2605
2803
|
}
|
|
@@ -2612,12 +2810,12 @@ class GroupExploder {
|
|
|
2612
2810
|
try {
|
|
2613
2811
|
m.updateMatrixWorld(true);
|
|
2614
2812
|
}
|
|
2615
|
-
catch
|
|
2813
|
+
catch { }
|
|
2616
2814
|
const s = new THREE.Vector3();
|
|
2617
2815
|
try {
|
|
2618
2816
|
m.getWorldPosition(s);
|
|
2619
2817
|
}
|
|
2620
|
-
catch
|
|
2818
|
+
catch {
|
|
2621
2819
|
s.set(0, 0, 0);
|
|
2622
2820
|
}
|
|
2623
2821
|
starts.push(s);
|
|
@@ -2715,7 +2913,7 @@ class GroupExploder {
|
|
|
2715
2913
|
});
|
|
2716
2914
|
}
|
|
2717
2915
|
// material dim with context id
|
|
2718
|
-
applyDimToOthers(explodingMeshes, opacity = 0.25,
|
|
2916
|
+
applyDimToOthers(explodingMeshes, opacity = 0.25, _opts) {
|
|
2719
2917
|
const contextId = `ctx_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
|
2720
2918
|
const explodingSet = new Set(explodingMeshes);
|
|
2721
2919
|
const touched = new Set();
|
|
@@ -2726,11 +2924,10 @@ class GroupExploder {
|
|
|
2726
2924
|
if (explodingSet.has(mesh))
|
|
2727
2925
|
return;
|
|
2728
2926
|
const applyMat = (mat) => {
|
|
2729
|
-
var _a;
|
|
2730
2927
|
if (!this.materialSnaps.has(mat)) {
|
|
2731
2928
|
this.materialSnaps.set(mat, {
|
|
2732
2929
|
transparent: !!mat.transparent,
|
|
2733
|
-
opacity:
|
|
2930
|
+
opacity: mat.opacity ?? 1,
|
|
2734
2931
|
depthWrite: mat.depthWrite,
|
|
2735
2932
|
});
|
|
2736
2933
|
}
|
|
@@ -2757,7 +2954,7 @@ class GroupExploder {
|
|
|
2757
2954
|
return contextId;
|
|
2758
2955
|
}
|
|
2759
2956
|
// clean contexts for meshes (restore materials whose contexts are removed)
|
|
2760
|
-
cleanContextsForMeshes(
|
|
2957
|
+
cleanContextsForMeshes(_meshes) {
|
|
2761
2958
|
// conservative strategy: for each context we created, delete it and restore materials accordingly
|
|
2762
2959
|
for (const [contextId, mats] of Array.from(this.contextMaterials.entries())) {
|
|
2763
2960
|
mats.forEach((mat) => {
|
|
@@ -2871,7 +3068,7 @@ class GroupExploder {
|
|
|
2871
3068
|
}
|
|
2872
3069
|
}
|
|
2873
3070
|
}
|
|
2874
|
-
catch
|
|
3071
|
+
catch {
|
|
2875
3072
|
radius = 0;
|
|
2876
3073
|
}
|
|
2877
3074
|
if (!isFinite(radius) || radius < 0 || radius > 1e8)
|
|
@@ -2894,10 +3091,9 @@ class GroupExploder {
|
|
|
2894
3091
|
}
|
|
2895
3092
|
// computeTargetsByMode (unchanged logic but pure function)
|
|
2896
3093
|
computeTargetsByMode(meshes, center, baseRadius, opts) {
|
|
2897
|
-
var _a, _b;
|
|
2898
3094
|
const n = meshes.length;
|
|
2899
|
-
const lift =
|
|
2900
|
-
const mode =
|
|
3095
|
+
const lift = opts.lift ?? 0.5;
|
|
3096
|
+
const mode = opts.mode ?? 'ring';
|
|
2901
3097
|
const targets = [];
|
|
2902
3098
|
if (mode === 'ring') {
|
|
2903
3099
|
for (let i = 0; i < n; i++) {
|
|
@@ -2939,274 +3135,244 @@ class GroupExploder {
|
|
|
2939
3135
|
return targets;
|
|
2940
3136
|
}
|
|
2941
3137
|
animateCameraToFit(targetCenter, targetRadius, opts) {
|
|
2942
|
-
|
|
2943
|
-
const
|
|
2944
|
-
const padding = (_b = opts === null || opts === void 0 ? void 0 : opts.padding) !== null && _b !== void 0 ? _b : 1.5;
|
|
3138
|
+
const duration = opts?.duration ?? 600;
|
|
3139
|
+
const padding = opts?.padding ?? 1.5;
|
|
2945
3140
|
if (!(this.camera instanceof THREE.PerspectiveCamera)) {
|
|
2946
3141
|
if (this.controls && this.controls.target) {
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
3142
|
+
// Fallback for non-PerspectiveCamera
|
|
3143
|
+
const startTarget = this.controls.target.clone();
|
|
3144
|
+
const startPos = this.camera.position.clone();
|
|
3145
|
+
const endTarget = targetCenter.clone();
|
|
3146
|
+
const dir = startPos.clone().sub(startTarget).normalize();
|
|
3147
|
+
const dist = startPos.distanceTo(startTarget);
|
|
3148
|
+
const endPos = endTarget.clone().add(dir.multiplyScalar(dist));
|
|
3149
|
+
const startTime = performance.now();
|
|
3150
|
+
const tick = (now) => {
|
|
3151
|
+
const t = Math.min(1, (now - startTime) / duration);
|
|
3152
|
+
const k = easeInOutQuad(t);
|
|
3153
|
+
if (this.controls && this.controls.target) {
|
|
3154
|
+
this.controls.target.lerpVectors(startTarget, endTarget, k);
|
|
3155
|
+
}
|
|
3156
|
+
this.camera.position.lerpVectors(startPos, endPos, k);
|
|
3157
|
+
if (this.controls?.update)
|
|
3158
|
+
this.controls.update();
|
|
3159
|
+
if (t < 1) {
|
|
3160
|
+
this.cameraAnimId = requestAnimationFrame(tick);
|
|
3161
|
+
}
|
|
3162
|
+
else {
|
|
3163
|
+
this.cameraAnimId = null;
|
|
3164
|
+
}
|
|
3165
|
+
};
|
|
3166
|
+
this.cameraAnimId = requestAnimationFrame(tick);
|
|
2950
3167
|
}
|
|
2951
3168
|
return Promise.resolve();
|
|
2952
3169
|
}
|
|
2953
|
-
|
|
2954
|
-
const fov = (
|
|
2955
|
-
const
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
3170
|
+
// PerspectiveCamera logic
|
|
3171
|
+
const fov = THREE.MathUtils.degToRad(this.camera.fov);
|
|
3172
|
+
const aspect = this.camera.aspect;
|
|
3173
|
+
// Calculate distance needed to fit the sphere
|
|
3174
|
+
// tan(fov/2) = radius / distance => distance = radius / tan(fov/2)
|
|
3175
|
+
// We also consider aspect ratio for horizontal fit
|
|
3176
|
+
const distV = targetRadius / Math.sin(fov / 2);
|
|
3177
|
+
const distH = targetRadius / Math.sin(Math.min(fov, fov * aspect) / 2); // approximate
|
|
3178
|
+
const dist = Math.max(distV, distH) * padding;
|
|
3179
|
+
const startPos = this.camera.position.clone();
|
|
3180
|
+
const startTarget = this.controls?.target ? this.controls.target.clone() : new THREE.Vector3(); // assumption
|
|
3181
|
+
if (!this.controls?.target) {
|
|
3182
|
+
this.camera.getWorldDirection(startTarget);
|
|
3183
|
+
startTarget.add(startPos);
|
|
3184
|
+
}
|
|
3185
|
+
// Determine end position: keep current viewing direction relative to center
|
|
3186
|
+
const dir = startPos.clone().sub(startTarget).normalize();
|
|
3187
|
+
if (dir.lengthSq() < 0.001)
|
|
2960
3188
|
dir.set(0, 0, 1);
|
|
2961
|
-
else
|
|
2962
|
-
dir.normalize();
|
|
2963
|
-
const newCamPos = targetCenter.clone().add(dir.multiplyScalar(desiredDistance));
|
|
2964
|
-
const startPos = cam.position.clone();
|
|
2965
|
-
const startTarget = (this.controls && this.controls.target) ? (this.controls.target.clone()) : this.getCameraLookAtPoint();
|
|
2966
3189
|
const endTarget = targetCenter.clone();
|
|
2967
|
-
const
|
|
3190
|
+
const endPos = endTarget.clone().add(dir.multiplyScalar(dist));
|
|
2968
3191
|
return new Promise((resolve) => {
|
|
3192
|
+
const startTime = performance.now();
|
|
2969
3193
|
const tick = (now) => {
|
|
2970
|
-
const t = Math.min(1, (now - startTime) /
|
|
2971
|
-
const
|
|
2972
|
-
|
|
2973
|
-
if (this.controls && this.controls.target)
|
|
2974
|
-
this.controls.target.lerpVectors(startTarget, endTarget,
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
3194
|
+
const t = Math.min(1, (now - startTime) / duration);
|
|
3195
|
+
const k = easeInOutQuad(t);
|
|
3196
|
+
this.camera.position.lerpVectors(startPos, endPos, k);
|
|
3197
|
+
if (this.controls && this.controls.target) {
|
|
3198
|
+
this.controls.target.lerpVectors(startTarget, endTarget, k);
|
|
3199
|
+
this.controls.update?.();
|
|
3200
|
+
}
|
|
3201
|
+
else {
|
|
3202
|
+
this.camera.lookAt(endTarget); // simple lookAt if no controls
|
|
3203
|
+
}
|
|
3204
|
+
if (t < 1) {
|
|
2979
3205
|
this.cameraAnimId = requestAnimationFrame(tick);
|
|
3206
|
+
}
|
|
2980
3207
|
else {
|
|
2981
3208
|
this.cameraAnimId = null;
|
|
2982
|
-
this.log(`animateCameraToFit: done. center=${targetCenter.toArray().map((n) => n.toFixed(2))}, radius=${targetRadius.toFixed(2)}`);
|
|
2983
3209
|
resolve();
|
|
2984
3210
|
}
|
|
2985
3211
|
};
|
|
2986
3212
|
this.cameraAnimId = requestAnimationFrame(tick);
|
|
2987
3213
|
});
|
|
2988
3214
|
}
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
return this.camera.position.clone().add(dir.multiplyScalar(10));
|
|
2993
|
-
}
|
|
3215
|
+
/**
|
|
3216
|
+
* Cancel all running animations
|
|
3217
|
+
*/
|
|
2994
3218
|
cancelAnimations() {
|
|
2995
|
-
if (this.animId) {
|
|
3219
|
+
if (this.animId !== null) {
|
|
2996
3220
|
cancelAnimationFrame(this.animId);
|
|
2997
3221
|
this.animId = null;
|
|
2998
3222
|
}
|
|
2999
|
-
if (this.cameraAnimId) {
|
|
3223
|
+
if (this.cameraAnimId !== null) {
|
|
3000
3224
|
cancelAnimationFrame(this.cameraAnimId);
|
|
3001
3225
|
this.cameraAnimId = null;
|
|
3002
3226
|
}
|
|
3003
3227
|
}
|
|
3228
|
+
/**
|
|
3229
|
+
* Dispose: remove listener, cancel animation, clear references
|
|
3230
|
+
*/
|
|
3004
3231
|
dispose() {
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
for (const [mat, ctxs] of Array.from(this.materialContexts.entries())) {
|
|
3015
|
-
const snap = this.materialSnaps.get(mat);
|
|
3016
|
-
if (snap) {
|
|
3017
|
-
mat.transparent = snap.transparent;
|
|
3018
|
-
mat.opacity = snap.opacity;
|
|
3019
|
-
if (typeof snap.depthWrite !== 'undefined')
|
|
3020
|
-
mat.depthWrite = snap.depthWrite;
|
|
3021
|
-
mat.needsUpdate = true;
|
|
3022
|
-
}
|
|
3023
|
-
this.materialContexts.delete(mat);
|
|
3024
|
-
this.materialSnaps.delete(mat);
|
|
3025
|
-
}
|
|
3026
|
-
this.contextMaterials.clear();
|
|
3027
|
-
this.stateMap.clear();
|
|
3028
|
-
this.prevStateMap.clear();
|
|
3029
|
-
this.currentSet = null;
|
|
3030
|
-
this.prevSet = null;
|
|
3031
|
-
this.isInitialized = false;
|
|
3032
|
-
this.isExploded = false;
|
|
3033
|
-
this.log('dispose: cleaned up');
|
|
3034
|
-
});
|
|
3232
|
+
this.cancelAnimations();
|
|
3233
|
+
this.currentSet = null;
|
|
3234
|
+
this.prevSet = null;
|
|
3235
|
+
this.stateMap.clear();
|
|
3236
|
+
this.prevStateMap.clear();
|
|
3237
|
+
this.materialContexts.clear();
|
|
3238
|
+
this.materialSnaps.clear();
|
|
3239
|
+
this.contextMaterials.clear();
|
|
3240
|
+
this.log('dispose() called, resources cleaned up');
|
|
3035
3241
|
}
|
|
3036
3242
|
}
|
|
3037
3243
|
|
|
3038
3244
|
/**
|
|
3039
|
-
*
|
|
3040
|
-
*
|
|
3041
|
-
*
|
|
3042
|
-
* - 添加灯光强度调整方法
|
|
3043
|
-
* - 完善错误处理
|
|
3044
|
-
* - 优化dispose逻辑
|
|
3045
|
-
*
|
|
3046
|
-
* - camera: THREE.PerspectiveCamera(会被移动并指向模型中心)
|
|
3047
|
-
* - scene: THREE.Scene(会把新创建的 light group 加入 scene)
|
|
3048
|
-
* - model: THREE.Object3D 已加载的模型(任意 transform/坐标)
|
|
3049
|
-
* - options: 可选配置(见 AutoSetupOptions)
|
|
3050
|
-
*
|
|
3051
|
-
* 返回 AutoSetupHandle,调用方在组件卸载/切换时请调用 handle.dispose()
|
|
3245
|
+
* @file autoSetup.ts
|
|
3246
|
+
* @description
|
|
3247
|
+
* Automatically sets up the camera and basic lighting scene based on the model's bounding box.
|
|
3052
3248
|
*/
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3249
|
+
/**
|
|
3250
|
+
* Fit camera to object bounding box
|
|
3251
|
+
*/
|
|
3252
|
+
function fitCameraToObject(camera, object, padding = 1.2, elevation = 0.2) {
|
|
3253
|
+
const box = new THREE.Box3().setFromObject(object);
|
|
3254
|
+
if (!isFinite(box.min.x))
|
|
3255
|
+
return { center: new THREE.Vector3(), radius: 0 };
|
|
3256
|
+
const sphere = new THREE.Sphere();
|
|
3257
|
+
box.getBoundingSphere(sphere);
|
|
3258
|
+
const center = sphere.center.clone();
|
|
3259
|
+
const radius = Math.max(0.001, sphere.radius);
|
|
3260
|
+
const fov = (camera.fov * Math.PI) / 180;
|
|
3261
|
+
const halfFov = fov / 2;
|
|
3262
|
+
const sinHalfFov = Math.max(Math.sin(halfFov), 0.001);
|
|
3263
|
+
const distance = (radius * padding) / sinHalfFov;
|
|
3264
|
+
const dir = new THREE.Vector3(0, Math.sin(elevation), Math.cos(elevation)).normalize();
|
|
3265
|
+
const desiredPos = center.clone().add(dir.multiplyScalar(distance));
|
|
3266
|
+
camera.position.copy(desiredPos);
|
|
3267
|
+
camera.lookAt(center);
|
|
3268
|
+
camera.near = Math.max(0.001, radius / 1000);
|
|
3269
|
+
camera.far = Math.max(1000, radius * 50);
|
|
3270
|
+
camera.updateProjectionMatrix();
|
|
3271
|
+
return { center, radius };
|
|
3272
|
+
}
|
|
3273
|
+
/**
|
|
3274
|
+
* Setup default lighting for a model
|
|
3275
|
+
*/
|
|
3276
|
+
function setupDefaultLights(scene, model, options = {}) {
|
|
3277
|
+
const box = new THREE.Box3().setFromObject(model);
|
|
3278
|
+
const sphere = new THREE.Sphere();
|
|
3279
|
+
box.getBoundingSphere(sphere);
|
|
3280
|
+
const center = sphere.center.clone();
|
|
3281
|
+
const radius = Math.max(0.001, sphere.radius);
|
|
3059
3282
|
const opts = {
|
|
3060
|
-
padding:
|
|
3061
|
-
elevation:
|
|
3062
|
-
enableShadows:
|
|
3063
|
-
shadowMapSize:
|
|
3064
|
-
directionalCount:
|
|
3065
|
-
setMeshShadowProps:
|
|
3066
|
-
renderer:
|
|
3283
|
+
padding: options.padding ?? 1.2,
|
|
3284
|
+
elevation: options.elevation ?? 0.2,
|
|
3285
|
+
enableShadows: options.enableShadows ?? false,
|
|
3286
|
+
shadowMapSize: options.shadowMapSize ?? 1024,
|
|
3287
|
+
directionalCount: options.directionalCount ?? 4,
|
|
3288
|
+
setMeshShadowProps: options.setMeshShadowProps ?? true,
|
|
3289
|
+
renderer: options.renderer ?? null,
|
|
3067
3290
|
};
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3291
|
+
if (opts.renderer && opts.enableShadows) {
|
|
3292
|
+
opts.renderer.shadowMap.enabled = true;
|
|
3293
|
+
opts.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
3294
|
+
}
|
|
3295
|
+
const lightsGroup = new THREE.Group();
|
|
3296
|
+
lightsGroup.name = 'autoSetupLightsGroup';
|
|
3297
|
+
lightsGroup.position.copy(center);
|
|
3298
|
+
scene.add(lightsGroup);
|
|
3299
|
+
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6);
|
|
3300
|
+
hemi.position.set(0, radius * 2.0, 0);
|
|
3301
|
+
lightsGroup.add(hemi);
|
|
3302
|
+
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
|
|
3303
|
+
lightsGroup.add(ambient);
|
|
3304
|
+
const dirCount = Math.max(1, Math.floor(opts.directionalCount));
|
|
3305
|
+
const dirs = [new THREE.Vector3(0, 1, 0)];
|
|
3306
|
+
for (let i = 0; i < dirCount; i++) {
|
|
3307
|
+
const angle = (i / dirCount) * Math.PI * 2;
|
|
3308
|
+
const v = new THREE.Vector3(Math.cos(angle), 0.3, Math.sin(angle)).normalize();
|
|
3309
|
+
dirs.push(v);
|
|
3310
|
+
}
|
|
3311
|
+
const shadowCamSize = Math.max(1, radius * 1.5);
|
|
3312
|
+
dirs.forEach((d, i) => {
|
|
3313
|
+
const light = new THREE.DirectionalLight(0xffffff, i === 0 ? 1.5 : 1.2);
|
|
3314
|
+
light.position.copy(d.clone().multiplyScalar(radius * 2.5));
|
|
3315
|
+
light.target.position.copy(center);
|
|
3316
|
+
light.name = `auto_dir_${i}`;
|
|
3317
|
+
lightsGroup.add(light);
|
|
3318
|
+
lightsGroup.add(light.target);
|
|
3319
|
+
if (opts.enableShadows) {
|
|
3320
|
+
light.castShadow = true;
|
|
3321
|
+
light.shadow.mapSize.width = opts.shadowMapSize;
|
|
3322
|
+
light.shadow.mapSize.height = opts.shadowMapSize;
|
|
3323
|
+
const cam = light.shadow.camera;
|
|
3324
|
+
const s = shadowCamSize;
|
|
3325
|
+
cam.left = -s;
|
|
3326
|
+
cam.right = s;
|
|
3327
|
+
cam.top = s;
|
|
3328
|
+
cam.bottom = -s;
|
|
3329
|
+
cam.near = 0.1;
|
|
3330
|
+
cam.far = radius * 10 + 50;
|
|
3331
|
+
light.shadow.bias = -5e-4;
|
|
3074
3332
|
}
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
}
|
|
3096
|
-
// --- 4) 创建灯光组
|
|
3097
|
-
const lightsGroup = new THREE.Group();
|
|
3098
|
-
lightsGroup.name = 'autoSetupLightsGroup';
|
|
3099
|
-
lightsGroup.position.copy(center);
|
|
3100
|
-
scene.add(lightsGroup);
|
|
3101
|
-
// 4.1 基础光
|
|
3102
|
-
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6);
|
|
3103
|
-
hemi.name = 'auto_hemi';
|
|
3104
|
-
hemi.position.set(0, radius * 2.0, 0);
|
|
3105
|
-
lightsGroup.add(hemi);
|
|
3106
|
-
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
|
|
3107
|
-
ambient.name = 'auto_ambient';
|
|
3108
|
-
lightsGroup.add(ambient);
|
|
3109
|
-
// 4.2 方向光
|
|
3110
|
-
const dirCount = Math.max(1, Math.floor(opts.directionalCount));
|
|
3111
|
-
const directionalLights = [];
|
|
3112
|
-
const dirs = [];
|
|
3113
|
-
dirs.push(new THREE.Vector3(0, 1, 0));
|
|
3114
|
-
for (let i = 0; i < Math.max(1, dirCount); i++) {
|
|
3115
|
-
const angle = (i / Math.max(1, dirCount)) * Math.PI * 2;
|
|
3116
|
-
const v = new THREE.Vector3(Math.cos(angle), 0.3, Math.sin(angle)).normalize();
|
|
3117
|
-
dirs.push(v);
|
|
3118
|
-
}
|
|
3119
|
-
const shadowCamSize = Math.max(1, radius * 1.5);
|
|
3120
|
-
for (let i = 0; i < dirs.length; i++) {
|
|
3121
|
-
const d = dirs[i];
|
|
3122
|
-
const light = new THREE.DirectionalLight(0xffffff, i === 0 ? 1.5 : 1.2);
|
|
3123
|
-
light.position.copy(d.clone().multiplyScalar(radius * 2.5));
|
|
3124
|
-
light.target.position.copy(center);
|
|
3125
|
-
light.name = `auto_dir_${i}`;
|
|
3126
|
-
lightsGroup.add(light);
|
|
3127
|
-
lightsGroup.add(light.target);
|
|
3128
|
-
if (opts.enableShadows) {
|
|
3129
|
-
light.castShadow = true;
|
|
3130
|
-
light.shadow.mapSize.width = opts.shadowMapSize;
|
|
3131
|
-
light.shadow.mapSize.height = opts.shadowMapSize;
|
|
3132
|
-
const cam = light.shadow.camera;
|
|
3133
|
-
const s = shadowCamSize;
|
|
3134
|
-
cam.left = -s;
|
|
3135
|
-
cam.right = s;
|
|
3136
|
-
cam.top = s;
|
|
3137
|
-
cam.bottom = -s;
|
|
3138
|
-
cam.near = 0.1;
|
|
3139
|
-
cam.far = radius * 10 + 50;
|
|
3140
|
-
light.shadow.bias = -0.0005;
|
|
3141
|
-
}
|
|
3142
|
-
directionalLights.push(light);
|
|
3143
|
-
}
|
|
3144
|
-
// 4.3 点光补光
|
|
3145
|
-
const fill1 = new THREE.PointLight(0xffffff, 0.5, radius * 4);
|
|
3146
|
-
fill1.position.copy(center).add(new THREE.Vector3(radius * 0.5, 0.2 * radius, 0));
|
|
3147
|
-
fill1.name = 'auto_fill1';
|
|
3148
|
-
lightsGroup.add(fill1);
|
|
3149
|
-
const fill2 = new THREE.PointLight(0xffffff, 0.3, radius * 3);
|
|
3150
|
-
fill2.position.copy(center).add(new THREE.Vector3(-radius * 0.5, -0.2 * radius, 0));
|
|
3151
|
-
fill2.name = 'auto_fill2';
|
|
3152
|
-
lightsGroup.add(fill2);
|
|
3153
|
-
// --- 5) 设置 Mesh 阴影属性
|
|
3154
|
-
if (opts.setMeshShadowProps) {
|
|
3155
|
-
model.traverse((ch) => {
|
|
3156
|
-
if (ch.isMesh) {
|
|
3157
|
-
const mesh = ch;
|
|
3158
|
-
const isSkinned = mesh.isSkinnedMesh;
|
|
3159
|
-
mesh.castShadow = opts.enableShadows && !isSkinned ? true : mesh.castShadow;
|
|
3160
|
-
mesh.receiveShadow = opts.enableShadows ? true : mesh.receiveShadow;
|
|
3333
|
+
});
|
|
3334
|
+
if (opts.setMeshShadowProps) {
|
|
3335
|
+
model.traverse((ch) => {
|
|
3336
|
+
if (ch.isMesh) {
|
|
3337
|
+
const mesh = ch;
|
|
3338
|
+
const isSkinned = mesh.isSkinnedMesh;
|
|
3339
|
+
mesh.castShadow = opts.enableShadows && !isSkinned ? true : mesh.castShadow;
|
|
3340
|
+
mesh.receiveShadow = opts.enableShadows ? true : mesh.receiveShadow;
|
|
3341
|
+
}
|
|
3342
|
+
});
|
|
3343
|
+
}
|
|
3344
|
+
const handle = {
|
|
3345
|
+
lightsGroup,
|
|
3346
|
+
center,
|
|
3347
|
+
radius,
|
|
3348
|
+
updateLightIntensity(factor) {
|
|
3349
|
+
lightsGroup.traverse((node) => {
|
|
3350
|
+
if (node.isLight) {
|
|
3351
|
+
const light = node;
|
|
3352
|
+
light.intensity *= factor; // Simple implementation
|
|
3161
3353
|
}
|
|
3162
3354
|
});
|
|
3163
|
-
}
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
if (node.isLight) {
|
|
3173
|
-
const light = node;
|
|
3174
|
-
const originalIntensity = parseFloat(light.name.split('_').pop() || '1');
|
|
3175
|
-
light.intensity = originalIntensity * Math.max(0, factor);
|
|
3176
|
-
}
|
|
3177
|
-
});
|
|
3178
|
-
},
|
|
3179
|
-
dispose: () => {
|
|
3180
|
-
try {
|
|
3181
|
-
// 移除灯光组
|
|
3182
|
-
if (lightsGroup.parent)
|
|
3183
|
-
lightsGroup.parent.remove(lightsGroup);
|
|
3184
|
-
// 清理阴影资源
|
|
3185
|
-
lightsGroup.traverse((node) => {
|
|
3186
|
-
if (node.isLight) {
|
|
3187
|
-
const l = node;
|
|
3188
|
-
if (l.shadow && l.shadow.map) {
|
|
3189
|
-
try {
|
|
3190
|
-
l.shadow.map.dispose();
|
|
3191
|
-
}
|
|
3192
|
-
catch (err) {
|
|
3193
|
-
console.warn('Failed to dispose shadow map:', err);
|
|
3194
|
-
}
|
|
3195
|
-
}
|
|
3196
|
-
}
|
|
3197
|
-
});
|
|
3198
|
-
}
|
|
3199
|
-
catch (error) {
|
|
3200
|
-
console.error('autoSetupCameraAndLight: dispose failed', error);
|
|
3355
|
+
},
|
|
3356
|
+
dispose: () => {
|
|
3357
|
+
if (lightsGroup.parent)
|
|
3358
|
+
lightsGroup.parent.remove(lightsGroup);
|
|
3359
|
+
lightsGroup.traverse((node) => {
|
|
3360
|
+
if (node.isLight) {
|
|
3361
|
+
const l = node;
|
|
3362
|
+
if (l.shadow && l.shadow.map)
|
|
3363
|
+
l.shadow.map.dispose();
|
|
3201
3364
|
}
|
|
3202
|
-
}
|
|
3203
|
-
}
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3365
|
+
});
|
|
3366
|
+
}
|
|
3367
|
+
};
|
|
3368
|
+
return handle;
|
|
3369
|
+
}
|
|
3370
|
+
/**
|
|
3371
|
+
* Automatically setup camera and basic lighting (Combine fitCameraToObject and setupDefaultLights)
|
|
3372
|
+
*/
|
|
3373
|
+
function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
3374
|
+
fitCameraToObject(camera, model, options.padding, options.elevation);
|
|
3375
|
+
return setupDefaultLights(scene, model, options);
|
|
3210
3376
|
}
|
|
3211
3377
|
|
|
3212
3378
|
/**
|
|
@@ -3216,8 +3382,8 @@ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
|
3216
3382
|
* @packageDocumentation
|
|
3217
3383
|
*/
|
|
3218
3384
|
// Core utilities
|
|
3219
|
-
// Version
|
|
3220
|
-
const VERSION = '1.0.
|
|
3385
|
+
// Version (keep in sync with package.json)
|
|
3386
|
+
const VERSION = '1.0.4';
|
|
3221
3387
|
|
|
3222
|
-
export { ArrowGuide, BlueSky, FOLLOW_ANGLES, GroupExploder, LiquidFillerGroup, VERSION, ViewPresets, addChildModelLabels, autoSetupCameraAndLight, cancelFollow, cancelSetView, createModelClickHandler, createModelsLabel, disposeMaterial, disposeObject, enableHoverBreath, followModels, initPostProcessing, loadCubeSkybox, loadEquirectSkybox, loadModelByUrl, loadSkybox, releaseSkybox, setView };
|
|
3388
|
+
export { ArrowGuide, BlueSky, FOLLOW_ANGLES, GroupExploder, LiquidFillerGroup, ResourceManager, VERSION, ViewPresets, addChildModelLabels, autoSetupCameraAndLight, cancelFollow, cancelSetView, createModelClickHandler, createModelsLabel, disposeMaterial, disposeObject, enableHoverBreath, fitCameraToObject, followModels, getLoaderConfig, initPostProcessing, loadCubeSkybox, loadEquirectSkybox, loadModelByUrl, loadSkybox, releaseSkybox, setLoaderConfig, setView, setupDefaultLights };
|
|
3223
3389
|
//# sourceMappingURL=index.mjs.map
|