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