@chocozhang/three-model-render 1.0.3 → 1.0.4
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/README.md +93 -96
- package/dist/camera/index.d.ts +59 -36
- package/dist/camera/index.js +77 -57
- package/dist/camera/index.js.map +1 -1
- package/dist/camera/index.mjs +77 -57
- package/dist/camera/index.mjs.map +1 -1
- package/dist/core/index.d.ts +60 -27
- package/dist/core/index.js +124 -95
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +124 -95
- package/dist/core/index.mjs.map +1 -1
- package/dist/effect/index.d.ts +47 -134
- package/dist/effect/index.js +109 -65
- package/dist/effect/index.js.map +1 -1
- package/dist/effect/index.mjs +109 -65
- package/dist/effect/index.mjs.map +1 -1
- package/dist/index.d.ts +397 -341
- package/dist/index.js +651 -472
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +651 -472
- package/dist/index.mjs.map +1 -1
- package/dist/interaction/index.d.ts +85 -52
- package/dist/interaction/index.js +161 -133
- package/dist/interaction/index.js.map +1 -1
- package/dist/interaction/index.mjs +161 -133
- package/dist/interaction/index.mjs.map +1 -1
- package/dist/loader/index.d.ts +89 -56
- package/dist/loader/index.js +115 -76
- package/dist/loader/index.js.map +1 -1
- package/dist/loader/index.mjs +115 -76
- package/dist/loader/index.mjs.map +1 -1
- package/dist/setup/index.d.ts +28 -18
- package/dist/setup/index.js +33 -24
- package/dist/setup/index.js.map +1 -1
- package/dist/setup/index.mjs +33 -24
- package/dist/setup/index.mjs.map +1 -1
- package/dist/ui/index.d.ts +18 -7
- package/dist/ui/index.js +32 -22
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/index.mjs +32 -22
- package/dist/ui/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -8,25 +8,35 @@ import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUti
|
|
|
8
8
|
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* @file labelManager.ts
|
|
12
|
+
* @description
|
|
13
|
+
* Manages HTML labels attached to 3D objects. Efficiently updates label positions based on camera movement.
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
17
|
-
|
|
15
|
+
* @best-practice
|
|
16
|
+
* - Use `addChildModelLabels` to label parts of a loaded model.
|
|
17
|
+
* - Labels are HTML elements overlaid on the canvas.
|
|
18
|
+
* - Supports performance optimization via caching and visibility culling.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Add overhead labels to child models (supports Mesh and Group)
|
|
22
|
+
*
|
|
23
|
+
* Features:
|
|
24
|
+
* - Caches bounding boxes to avoid repetitive calculation every frame
|
|
25
|
+
* - Supports pause/resume
|
|
26
|
+
* - Configurable update interval to reduce CPU usage
|
|
27
|
+
* - Automatically pauses when hidden
|
|
18
28
|
*
|
|
19
|
-
* @param camera THREE.Camera -
|
|
20
|
-
* @param renderer THREE.WebGLRenderer -
|
|
21
|
-
* @param parentModel THREE.Object3D - FBX
|
|
22
|
-
* @param modelLabelsMap Record<string,string> -
|
|
23
|
-
* @param options LabelOptions -
|
|
24
|
-
* @returns LabelManager -
|
|
29
|
+
* @param camera THREE.Camera - Scene camera
|
|
30
|
+
* @param renderer THREE.WebGLRenderer - Renderer, used for screen size
|
|
31
|
+
* @param parentModel THREE.Object3D - FBX root node or Group
|
|
32
|
+
* @param modelLabelsMap Record<string,string> - Map of model name to label text
|
|
33
|
+
* @param options LabelOptions - Optional label style configuration
|
|
34
|
+
* @returns LabelManager - Management interface containing pause/resume/dispose
|
|
25
35
|
*/
|
|
26
36
|
function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, options) {
|
|
27
|
-
//
|
|
37
|
+
// Defensive check: ensure parentModel is loaded
|
|
28
38
|
if (!parentModel || typeof parentModel.traverse !== 'function') {
|
|
29
|
-
console.error('parentModel
|
|
39
|
+
console.error('parentModel invalid, please ensure the FBX model is loaded');
|
|
30
40
|
return {
|
|
31
41
|
pause: () => { },
|
|
32
42
|
resume: () => { },
|
|
@@ -34,48 +44,48 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
34
44
|
isRunning: () => false
|
|
35
45
|
};
|
|
36
46
|
}
|
|
37
|
-
//
|
|
47
|
+
// Configuration
|
|
38
48
|
const enableCache = (options === null || options === void 0 ? void 0 : options.enableCache) !== false;
|
|
39
49
|
const updateInterval = (options === null || options === void 0 ? void 0 : options.updateInterval) || 0;
|
|
40
|
-
//
|
|
50
|
+
// Create label container, absolute positioning, attached to body
|
|
41
51
|
const container = document.createElement('div');
|
|
42
52
|
container.style.position = 'absolute';
|
|
43
53
|
container.style.top = '0';
|
|
44
54
|
container.style.left = '0';
|
|
45
|
-
container.style.pointerEvents = 'none'; //
|
|
55
|
+
container.style.pointerEvents = 'none'; // Avoid blocking mouse events
|
|
46
56
|
container.style.zIndex = '1000';
|
|
47
57
|
document.body.appendChild(container);
|
|
48
58
|
const labels = [];
|
|
49
|
-
//
|
|
59
|
+
// State management
|
|
50
60
|
let rafId = null;
|
|
51
61
|
let isPaused = false;
|
|
52
62
|
let lastUpdateTime = 0;
|
|
53
|
-
//
|
|
63
|
+
// Traverse all child models
|
|
54
64
|
parentModel.traverse((child) => {
|
|
55
65
|
var _a;
|
|
56
|
-
//
|
|
66
|
+
// Only process Mesh or Group
|
|
57
67
|
if ((child.isMesh || child.type === 'Group')) {
|
|
58
|
-
//
|
|
68
|
+
// Dynamic matching of name to prevent undefined
|
|
59
69
|
const labelText = (_a = Object.entries(modelLabelsMap).find(([key]) => child.name.includes(key))) === null || _a === void 0 ? void 0 : _a[1];
|
|
60
70
|
if (!labelText)
|
|
61
|
-
return; //
|
|
62
|
-
//
|
|
71
|
+
return; // Skip if no matching label
|
|
72
|
+
// Create DOM label
|
|
63
73
|
const el = document.createElement('div');
|
|
64
74
|
el.innerText = labelText;
|
|
65
|
-
//
|
|
75
|
+
// Styles defined in JS, can be overridden via options
|
|
66
76
|
el.style.position = 'absolute';
|
|
67
77
|
el.style.color = (options === null || options === void 0 ? void 0 : options.color) || '#fff';
|
|
68
78
|
el.style.background = (options === null || options === void 0 ? void 0 : options.background) || 'rgba(0,0,0,0.6)';
|
|
69
79
|
el.style.padding = (options === null || options === void 0 ? void 0 : options.padding) || '4px 8px';
|
|
70
80
|
el.style.borderRadius = (options === null || options === void 0 ? void 0 : options.borderRadius) || '4px';
|
|
71
81
|
el.style.fontSize = (options === null || options === void 0 ? void 0 : options.fontSize) || '14px';
|
|
72
|
-
el.style.transform = 'translate(-50%, -100%)'; //
|
|
82
|
+
el.style.transform = 'translate(-50%, -100%)'; // Position label directly above the model
|
|
73
83
|
el.style.whiteSpace = 'nowrap';
|
|
74
84
|
el.style.pointerEvents = 'none';
|
|
75
85
|
el.style.transition = 'opacity 0.2s ease';
|
|
76
|
-
//
|
|
86
|
+
// Append to container
|
|
77
87
|
container.appendChild(el);
|
|
78
|
-
//
|
|
88
|
+
// Initialize cache
|
|
79
89
|
const cachedBox = new THREE.Box3().setFromObject(child);
|
|
80
90
|
const center = new THREE.Vector3();
|
|
81
91
|
cachedBox.getCenter(center);
|
|
@@ -90,7 +100,7 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
90
100
|
}
|
|
91
101
|
});
|
|
92
102
|
/**
|
|
93
|
-
*
|
|
103
|
+
* Update cached bounding box (called only when model transforms)
|
|
94
104
|
*/
|
|
95
105
|
const updateCache = (labelData) => {
|
|
96
106
|
labelData.cachedBox.setFromObject(labelData.object);
|
|
@@ -100,18 +110,18 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
100
110
|
labelData.needsUpdate = false;
|
|
101
111
|
};
|
|
102
112
|
/**
|
|
103
|
-
*
|
|
113
|
+
* Get object top world coordinates (using cache)
|
|
104
114
|
*/
|
|
105
115
|
const getObjectTopPosition = (labelData) => {
|
|
106
116
|
if (enableCache) {
|
|
107
|
-
//
|
|
117
|
+
// Check if object has transformed
|
|
108
118
|
if (labelData.needsUpdate || labelData.object.matrixWorldNeedsUpdate) {
|
|
109
119
|
updateCache(labelData);
|
|
110
120
|
}
|
|
111
121
|
return labelData.cachedTopPos;
|
|
112
122
|
}
|
|
113
123
|
else {
|
|
114
|
-
//
|
|
124
|
+
// Do not use cache, recalculate every time
|
|
115
125
|
const box = new THREE.Box3().setFromObject(labelData.object);
|
|
116
126
|
const center = new THREE.Vector3();
|
|
117
127
|
box.getCenter(center);
|
|
@@ -119,15 +129,15 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
119
129
|
}
|
|
120
130
|
};
|
|
121
131
|
/**
|
|
122
|
-
*
|
|
132
|
+
* Update label positions function
|
|
123
133
|
*/
|
|
124
134
|
function updateLabels(timestamp = 0) {
|
|
125
|
-
//
|
|
135
|
+
// Check pause state
|
|
126
136
|
if (isPaused) {
|
|
127
137
|
rafId = null;
|
|
128
138
|
return;
|
|
129
139
|
}
|
|
130
|
-
//
|
|
140
|
+
// Check update interval
|
|
131
141
|
if (updateInterval > 0 && timestamp - lastUpdateTime < updateInterval) {
|
|
132
142
|
rafId = requestAnimationFrame(updateLabels);
|
|
133
143
|
return;
|
|
@@ -137,22 +147,22 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
137
147
|
const height = renderer.domElement.clientHeight;
|
|
138
148
|
labels.forEach((labelData) => {
|
|
139
149
|
const { el } = labelData;
|
|
140
|
-
const pos = getObjectTopPosition(labelData); //
|
|
141
|
-
pos.project(camera); //
|
|
142
|
-
const x = (pos.x * 0.5 + 0.5) * width; //
|
|
143
|
-
const y = (-(pos.y * 0.5) + 0.5) * height; //
|
|
144
|
-
//
|
|
150
|
+
const pos = getObjectTopPosition(labelData); // Use cached top position
|
|
151
|
+
pos.project(camera); // Convert to screen coordinates
|
|
152
|
+
const x = (pos.x * 0.5 + 0.5) * width; // Screen X
|
|
153
|
+
const y = (-(pos.y * 0.5) + 0.5) * height; // Screen Y
|
|
154
|
+
// Control label visibility (hidden when behind camera)
|
|
145
155
|
const isVisible = pos.z < 1;
|
|
146
156
|
el.style.opacity = isVisible ? '1' : '0';
|
|
147
157
|
el.style.display = isVisible ? 'block' : 'none';
|
|
148
|
-
el.style.transform = `translate(-50%, -100%) translate(${x}px, ${y}px)`; //
|
|
158
|
+
el.style.transform = `translate(-50%, -100%) translate(${x}px, ${y}px)`; // Screen position
|
|
149
159
|
});
|
|
150
|
-
rafId = requestAnimationFrame(updateLabels); //
|
|
160
|
+
rafId = requestAnimationFrame(updateLabels); // Loop update
|
|
151
161
|
}
|
|
152
|
-
//
|
|
162
|
+
// Start update
|
|
153
163
|
updateLabels();
|
|
154
164
|
/**
|
|
155
|
-
*
|
|
165
|
+
* Pause updates
|
|
156
166
|
*/
|
|
157
167
|
const pause = () => {
|
|
158
168
|
isPaused = true;
|
|
@@ -162,7 +172,7 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
162
172
|
}
|
|
163
173
|
};
|
|
164
174
|
/**
|
|
165
|
-
*
|
|
175
|
+
* Resume updates
|
|
166
176
|
*/
|
|
167
177
|
const resume = () => {
|
|
168
178
|
if (!isPaused)
|
|
@@ -171,11 +181,11 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
171
181
|
updateLabels();
|
|
172
182
|
};
|
|
173
183
|
/**
|
|
174
|
-
*
|
|
184
|
+
* Check if running
|
|
175
185
|
*/
|
|
176
186
|
const isRunning = () => !isPaused;
|
|
177
187
|
/**
|
|
178
|
-
*
|
|
188
|
+
* Cleanup function: Remove all DOM labels, cancel animation, avoid memory leaks
|
|
179
189
|
*/
|
|
180
190
|
const dispose = () => {
|
|
181
191
|
pause();
|
|
@@ -197,36 +207,45 @@ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, opti
|
|
|
197
207
|
};
|
|
198
208
|
}
|
|
199
209
|
|
|
200
|
-
// src/utils/hoverBreathEffectByNameSingleton.ts
|
|
201
210
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
211
|
+
* @file hoverEffect.ts
|
|
212
|
+
* @description
|
|
213
|
+
* Singleton highlight effect manager. Uses OutlinePass to create a breathing highlight effect on hovered objects.
|
|
214
|
+
*
|
|
215
|
+
* @best-practice
|
|
216
|
+
* - Initialize once in your setup/mounted hook.
|
|
217
|
+
* - Call `updateHighlightNames` to filter which objects are interactive.
|
|
218
|
+
* - Automatically handles mousemove throttling and cleanup on dispose.
|
|
219
|
+
*/
|
|
220
|
+
/**
|
|
221
|
+
* Create a singleton highlighter - Recommended to create once on mount
|
|
222
|
+
* Returns { updateHighlightNames, dispose, getHoveredName } interface
|
|
204
223
|
*
|
|
205
|
-
*
|
|
206
|
-
* -
|
|
207
|
-
* - mousemove
|
|
208
|
-
* -
|
|
224
|
+
* Features:
|
|
225
|
+
* - Automatically pauses animation when no object is hovered
|
|
226
|
+
* - Throttles mousemove events to avoid excessive calculation
|
|
227
|
+
* - Uses passive event listeners to improve scrolling performance
|
|
209
228
|
*/
|
|
210
229
|
function enableHoverBreath(opts) {
|
|
211
|
-
const { camera, scene, renderer, outlinePass, highlightNames = null, minStrength = 2, maxStrength = 5, speed = 4, throttleDelay = 16, //
|
|
230
|
+
const { camera, scene, renderer, outlinePass, highlightNames = null, minStrength = 2, maxStrength = 5, speed = 4, throttleDelay = 16, // Default ~60fps
|
|
212
231
|
} = opts;
|
|
213
232
|
const raycaster = new THREE.Raycaster();
|
|
214
233
|
const mouse = new THREE.Vector2();
|
|
215
234
|
let hovered = null;
|
|
216
235
|
let time = 0;
|
|
217
236
|
let animationId = null;
|
|
218
|
-
// highlightSet: null
|
|
237
|
+
// highlightSet: null means all; empty Set means none
|
|
219
238
|
let highlightSet = highlightNames === null ? null : new Set(highlightNames);
|
|
220
|
-
//
|
|
239
|
+
// Throttling related
|
|
221
240
|
let lastMoveTime = 0;
|
|
222
241
|
let rafPending = false;
|
|
223
242
|
function setHighlightNames(names) {
|
|
224
243
|
highlightSet = names === null ? null : new Set(names);
|
|
225
|
-
//
|
|
244
|
+
// If current hovered object is not in the new list, clean up selection immediately
|
|
226
245
|
if (hovered && highlightSet && !highlightSet.has(hovered.name)) {
|
|
227
246
|
hovered = null;
|
|
228
247
|
outlinePass.selectedObjects = [];
|
|
229
|
-
//
|
|
248
|
+
// Pause animation
|
|
230
249
|
if (animationId !== null) {
|
|
231
250
|
cancelAnimationFrame(animationId);
|
|
232
251
|
animationId = null;
|
|
@@ -234,13 +253,13 @@ function enableHoverBreath(opts) {
|
|
|
234
253
|
}
|
|
235
254
|
}
|
|
236
255
|
/**
|
|
237
|
-
*
|
|
256
|
+
* Throttled mousemove handler
|
|
238
257
|
*/
|
|
239
258
|
function onMouseMove(ev) {
|
|
240
259
|
const now = performance.now();
|
|
241
|
-
//
|
|
260
|
+
// Throttle: if time since last process is less than threshold, skip
|
|
242
261
|
if (now - lastMoveTime < throttleDelay) {
|
|
243
|
-
//
|
|
262
|
+
// Use RAF to process the latest event later, ensuring the last event isn't lost
|
|
244
263
|
if (!rafPending) {
|
|
245
264
|
rafPending = true;
|
|
246
265
|
requestAnimationFrame(() => {
|
|
@@ -254,24 +273,24 @@ function enableHoverBreath(opts) {
|
|
|
254
273
|
processMouseMove(ev);
|
|
255
274
|
}
|
|
256
275
|
/**
|
|
257
|
-
*
|
|
276
|
+
* Actual mousemove logic
|
|
258
277
|
*/
|
|
259
278
|
function processMouseMove(ev) {
|
|
260
279
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
261
280
|
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
|
|
262
281
|
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
|
|
263
282
|
raycaster.setFromCamera(mouse, camera);
|
|
264
|
-
//
|
|
283
|
+
// Deep detect all children of the scene (true)
|
|
265
284
|
const intersects = raycaster.intersectObjects(scene.children, true);
|
|
266
285
|
if (intersects.length > 0) {
|
|
267
286
|
const obj = intersects[0].object;
|
|
268
|
-
//
|
|
287
|
+
// Determine if it is allowed to be highlighted
|
|
269
288
|
const allowed = highlightSet === null ? true : highlightSet.has(obj.name);
|
|
270
289
|
if (allowed) {
|
|
271
290
|
if (hovered !== obj) {
|
|
272
291
|
hovered = obj;
|
|
273
292
|
outlinePass.selectedObjects = [obj];
|
|
274
|
-
//
|
|
293
|
+
// Start animation (if not running)
|
|
275
294
|
if (animationId === null) {
|
|
276
295
|
animate();
|
|
277
296
|
}
|
|
@@ -281,7 +300,7 @@ function enableHoverBreath(opts) {
|
|
|
281
300
|
if (hovered !== null) {
|
|
282
301
|
hovered = null;
|
|
283
302
|
outlinePass.selectedObjects = [];
|
|
284
|
-
//
|
|
303
|
+
// Stop animation
|
|
285
304
|
if (animationId !== null) {
|
|
286
305
|
cancelAnimationFrame(animationId);
|
|
287
306
|
animationId = null;
|
|
@@ -293,7 +312,7 @@ function enableHoverBreath(opts) {
|
|
|
293
312
|
if (hovered !== null) {
|
|
294
313
|
hovered = null;
|
|
295
314
|
outlinePass.selectedObjects = [];
|
|
296
|
-
//
|
|
315
|
+
// Stop animation
|
|
297
316
|
if (animationId !== null) {
|
|
298
317
|
cancelAnimationFrame(animationId);
|
|
299
318
|
animationId = null;
|
|
@@ -302,10 +321,10 @@ function enableHoverBreath(opts) {
|
|
|
302
321
|
}
|
|
303
322
|
}
|
|
304
323
|
/**
|
|
305
|
-
*
|
|
324
|
+
* Animation loop - only runs when there is a hovered object
|
|
306
325
|
*/
|
|
307
326
|
function animate() {
|
|
308
|
-
//
|
|
327
|
+
// If no hovered object, stop animation
|
|
309
328
|
if (!hovered) {
|
|
310
329
|
animationId = null;
|
|
311
330
|
return;
|
|
@@ -315,11 +334,11 @@ function enableHoverBreath(opts) {
|
|
|
315
334
|
const strength = minStrength + ((Math.sin(time) + 1) / 2) * (maxStrength - minStrength);
|
|
316
335
|
outlinePass.edgeStrength = strength;
|
|
317
336
|
}
|
|
318
|
-
//
|
|
319
|
-
//
|
|
337
|
+
// Start (called only once)
|
|
338
|
+
// Use passive to improve scrolling performance
|
|
320
339
|
renderer.domElement.addEventListener('mousemove', onMouseMove, { passive: true });
|
|
321
|
-
//
|
|
322
|
-
// refresh:
|
|
340
|
+
// Note: Do not start animate here, wait until there is a hover object
|
|
341
|
+
// refresh: Forcibly clean up selectedObjects if needed
|
|
323
342
|
function refreshSelection() {
|
|
324
343
|
if (hovered && highlightSet && !highlightSet.has(hovered.name)) {
|
|
325
344
|
hovered = null;
|
|
@@ -340,7 +359,7 @@ function enableHoverBreath(opts) {
|
|
|
340
359
|
animationId = null;
|
|
341
360
|
}
|
|
342
361
|
outlinePass.selectedObjects = [];
|
|
343
|
-
//
|
|
362
|
+
// Clear references
|
|
344
363
|
hovered = null;
|
|
345
364
|
highlightSet = null;
|
|
346
365
|
}
|
|
@@ -353,23 +372,33 @@ function enableHoverBreath(opts) {
|
|
|
353
372
|
}
|
|
354
373
|
|
|
355
374
|
/**
|
|
356
|
-
*
|
|
375
|
+
* @file postProcessing.ts
|
|
376
|
+
* @description
|
|
377
|
+
* Manages the post-processing chain, specifically for Outline effects and Gamma correction.
|
|
357
378
|
*
|
|
358
|
-
*
|
|
359
|
-
* -
|
|
360
|
-
* -
|
|
361
|
-
* -
|
|
379
|
+
* @best-practice
|
|
380
|
+
* - call `initPostProcessing` after creating your renderer and scene.
|
|
381
|
+
* - Use the returned `composer` in your render loop instead of `renderer.render`.
|
|
382
|
+
* - Handles resizing automatically via the `resize` method.
|
|
383
|
+
*/
|
|
384
|
+
/**
|
|
385
|
+
* Initialize outline-related information (contains OutlinePass)
|
|
386
|
+
*
|
|
387
|
+
* Capabilities:
|
|
388
|
+
* - Supports automatic update on window resize
|
|
389
|
+
* - Configurable resolution scale for performance improvement
|
|
390
|
+
* - Comprehensive resource disposal management
|
|
362
391
|
*
|
|
363
392
|
* @param renderer THREE.WebGLRenderer
|
|
364
393
|
* @param scene THREE.Scene
|
|
365
394
|
* @param camera THREE.Camera
|
|
366
|
-
* @param options PostProcessingOptions -
|
|
367
|
-
* @returns PostProcessingManager -
|
|
395
|
+
* @param options PostProcessingOptions - Optional configuration
|
|
396
|
+
* @returns PostProcessingManager - Management interface containing composer/outlinePass/resize/dispose
|
|
368
397
|
*/
|
|
369
398
|
function initPostProcessing(renderer, scene, camera, options = {}) {
|
|
370
|
-
//
|
|
399
|
+
// Default configuration
|
|
371
400
|
const { edgeStrength = 4, edgeGlow = 1, edgeThickness = 2, visibleEdgeColor = '#ffee00', hiddenEdgeColor = '#000000', resolutionScale = 1.0 } = options;
|
|
372
|
-
//
|
|
401
|
+
// Get renderer actual size
|
|
373
402
|
const getSize = () => {
|
|
374
403
|
const width = renderer.domElement.clientWidth;
|
|
375
404
|
const height = renderer.domElement.clientHeight;
|
|
@@ -379,12 +408,12 @@ function initPostProcessing(renderer, scene, camera, options = {}) {
|
|
|
379
408
|
};
|
|
380
409
|
};
|
|
381
410
|
const size = getSize();
|
|
382
|
-
//
|
|
411
|
+
// Create EffectComposer
|
|
383
412
|
const composer = new EffectComposer(renderer);
|
|
384
|
-
//
|
|
413
|
+
// Basic RenderPass
|
|
385
414
|
const renderPass = new RenderPass(scene, camera);
|
|
386
415
|
composer.addPass(renderPass);
|
|
387
|
-
// OutlinePass
|
|
416
|
+
// OutlinePass for model outlining
|
|
388
417
|
const outlinePass = new OutlinePass(new THREE.Vector2(size.width, size.height), scene, camera);
|
|
389
418
|
outlinePass.edgeStrength = edgeStrength;
|
|
390
419
|
outlinePass.edgeGlow = edgeGlow;
|
|
@@ -392,34 +421,34 @@ function initPostProcessing(renderer, scene, camera, options = {}) {
|
|
|
392
421
|
outlinePass.visibleEdgeColor.set(visibleEdgeColor);
|
|
393
422
|
outlinePass.hiddenEdgeColor.set(hiddenEdgeColor);
|
|
394
423
|
composer.addPass(outlinePass);
|
|
395
|
-
// Gamma
|
|
424
|
+
// Gamma correction
|
|
396
425
|
const gammaPass = new ShaderPass(GammaCorrectionShader);
|
|
397
426
|
composer.addPass(gammaPass);
|
|
398
427
|
/**
|
|
399
|
-
* resize
|
|
400
|
-
* @param width
|
|
401
|
-
* @param height
|
|
428
|
+
* Handle resize
|
|
429
|
+
* @param width Optional width, uses renderer.domElement actual width if not provided
|
|
430
|
+
* @param height Optional height, uses renderer.domElement actual height if not provided
|
|
402
431
|
*/
|
|
403
432
|
const resize = (width, height) => {
|
|
404
433
|
const actualSize = width !== undefined && height !== undefined
|
|
405
434
|
? { width: Math.floor(width * resolutionScale), height: Math.floor(height * resolutionScale) }
|
|
406
435
|
: getSize();
|
|
407
|
-
//
|
|
436
|
+
// Update composer size
|
|
408
437
|
composer.setSize(actualSize.width, actualSize.height);
|
|
409
|
-
//
|
|
438
|
+
// Update outlinePass resolution
|
|
410
439
|
outlinePass.resolution.set(actualSize.width, actualSize.height);
|
|
411
440
|
};
|
|
412
441
|
/**
|
|
413
|
-
*
|
|
442
|
+
* Dispose resources
|
|
414
443
|
*/
|
|
415
444
|
const dispose = () => {
|
|
416
|
-
//
|
|
445
|
+
// Dipose all passes
|
|
417
446
|
composer.passes.forEach(pass => {
|
|
418
447
|
if (pass.dispose) {
|
|
419
448
|
pass.dispose();
|
|
420
449
|
}
|
|
421
450
|
});
|
|
422
|
-
//
|
|
451
|
+
// Clear passes array
|
|
423
452
|
composer.passes.length = 0;
|
|
424
453
|
};
|
|
425
454
|
return {
|
|
@@ -431,28 +460,38 @@ function initPostProcessing(renderer, scene, camera, options = {}) {
|
|
|
431
460
|
}
|
|
432
461
|
|
|
433
462
|
/**
|
|
434
|
-
*
|
|
463
|
+
* @file clickHandler.ts
|
|
464
|
+
* @description
|
|
465
|
+
* Tool for handling model clicks and highlighting (OutlinePass version).
|
|
466
|
+
*
|
|
467
|
+
* @best-practice
|
|
468
|
+
* - Use `createModelClickHandler` to setup interaction.
|
|
469
|
+
* - Handles debouncing and click threshold automatically.
|
|
470
|
+
* - Cleanup using the returned dispose function.
|
|
471
|
+
*/
|
|
472
|
+
/**
|
|
473
|
+
* Create Model Click Highlight Tool (OutlinePass Version) - Optimized
|
|
435
474
|
*
|
|
436
|
-
*
|
|
437
|
-
* -
|
|
438
|
-
* -
|
|
439
|
-
* -
|
|
440
|
-
* -
|
|
475
|
+
* Features:
|
|
476
|
+
* - Uses AbortController to unify event lifecycle management
|
|
477
|
+
* - Supports debounce to avoid frequent triggering
|
|
478
|
+
* - Customizable Raycaster parameters
|
|
479
|
+
* - Dynamically adjusts outline thickness based on camera distance
|
|
441
480
|
*
|
|
442
|
-
* @param camera
|
|
443
|
-
* @param scene
|
|
444
|
-
* @param renderer
|
|
445
|
-
* @param outlinePass
|
|
446
|
-
* @param onClick
|
|
447
|
-
* @param options
|
|
448
|
-
* @returns
|
|
481
|
+
* @param camera Camera
|
|
482
|
+
* @param scene Scene
|
|
483
|
+
* @param renderer Renderer
|
|
484
|
+
* @param outlinePass Initialized OutlinePass
|
|
485
|
+
* @param onClick Click callback
|
|
486
|
+
* @param options Optional configuration
|
|
487
|
+
* @returns Dispose function, used to clean up events and resources
|
|
449
488
|
*/
|
|
450
489
|
function createModelClickHandler(camera, scene, renderer, outlinePass, onClick, options = {}) {
|
|
451
|
-
//
|
|
490
|
+
// Configuration
|
|
452
491
|
const { clickThreshold = 3, debounceDelay = 0, raycasterParams = {}, enableDynamicThickness = true, minThickness = 1, maxThickness = 10 } = options;
|
|
453
492
|
const raycaster = new THREE.Raycaster();
|
|
454
493
|
const mouse = new THREE.Vector2();
|
|
455
|
-
//
|
|
494
|
+
// Apply Raycaster custom parameters
|
|
456
495
|
if (raycasterParams.near !== undefined)
|
|
457
496
|
raycaster.near = raycasterParams.near;
|
|
458
497
|
if (raycasterParams.far !== undefined)
|
|
@@ -469,25 +508,25 @@ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick,
|
|
|
469
508
|
let startY = 0;
|
|
470
509
|
let selectedObject = null;
|
|
471
510
|
let debounceTimer = null;
|
|
472
|
-
//
|
|
511
|
+
// Use AbortController to manage events uniformly
|
|
473
512
|
const abortController = new AbortController();
|
|
474
513
|
const signal = abortController.signal;
|
|
475
|
-
/**
|
|
514
|
+
/** Restore object highlight (Clear OutlinePass.selectedObjects) */
|
|
476
515
|
function restoreObject() {
|
|
477
516
|
outlinePass.selectedObjects = [];
|
|
478
517
|
}
|
|
479
|
-
/**
|
|
518
|
+
/** Record mouse down position */
|
|
480
519
|
function handleMouseDown(event) {
|
|
481
520
|
startX = event.clientX;
|
|
482
521
|
startY = event.clientY;
|
|
483
522
|
}
|
|
484
|
-
/**
|
|
523
|
+
/** Mouse up determines click or drag (with debounce) */
|
|
485
524
|
function handleMouseUp(event) {
|
|
486
525
|
const dx = Math.abs(event.clientX - startX);
|
|
487
526
|
const dy = Math.abs(event.clientY - startY);
|
|
488
527
|
if (dx > clickThreshold || dy > clickThreshold)
|
|
489
|
-
return; //
|
|
490
|
-
//
|
|
528
|
+
return; // Drag does not trigger click
|
|
529
|
+
// Debounce processing
|
|
491
530
|
if (debounceDelay > 0) {
|
|
492
531
|
if (debounceTimer !== null) {
|
|
493
532
|
clearTimeout(debounceTimer);
|
|
@@ -501,7 +540,7 @@ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick,
|
|
|
501
540
|
processClick(event);
|
|
502
541
|
}
|
|
503
542
|
}
|
|
504
|
-
/**
|
|
543
|
+
/** Actual click processing logic */
|
|
505
544
|
function processClick(event) {
|
|
506
545
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
507
546
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
@@ -510,55 +549,64 @@ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick,
|
|
|
510
549
|
const intersects = raycaster.intersectObjects(scene.children, true);
|
|
511
550
|
if (intersects.length > 0) {
|
|
512
551
|
let object = intersects[0].object;
|
|
513
|
-
//
|
|
552
|
+
// Click different model, clear previous highlight first
|
|
514
553
|
if (selectedObject && selectedObject !== object)
|
|
515
554
|
restoreObject();
|
|
516
555
|
selectedObject = object;
|
|
517
|
-
// highlightObject(selectedObject); //
|
|
556
|
+
// highlightObject(selectedObject); // Optional: whether to auto highlight
|
|
518
557
|
onClick(selectedObject, {
|
|
519
|
-
name: selectedObject.name || '
|
|
558
|
+
name: selectedObject.name || 'Unnamed Model',
|
|
520
559
|
position: selectedObject.getWorldPosition(new THREE.Vector3()),
|
|
521
560
|
uuid: selectedObject.uuid
|
|
522
561
|
});
|
|
523
562
|
}
|
|
524
563
|
else {
|
|
525
|
-
//
|
|
564
|
+
// Click blank -> Clear highlight
|
|
526
565
|
if (selectedObject)
|
|
527
566
|
restoreObject();
|
|
528
567
|
selectedObject = null;
|
|
529
568
|
onClick(null);
|
|
530
569
|
}
|
|
531
570
|
}
|
|
532
|
-
//
|
|
571
|
+
// Register events using signal from AbortController
|
|
533
572
|
renderer.domElement.addEventListener('mousedown', handleMouseDown, { signal });
|
|
534
573
|
renderer.domElement.addEventListener('mouseup', handleMouseUp, { signal });
|
|
535
|
-
/**
|
|
574
|
+
/** Dispose function: Unbind events and clear highlight */
|
|
536
575
|
return () => {
|
|
537
|
-
//
|
|
576
|
+
// Clear debounce timer
|
|
538
577
|
if (debounceTimer !== null) {
|
|
539
578
|
clearTimeout(debounceTimer);
|
|
540
579
|
debounceTimer = null;
|
|
541
580
|
}
|
|
542
|
-
//
|
|
581
|
+
// Unbind all events at once
|
|
543
582
|
abortController.abort();
|
|
544
|
-
//
|
|
583
|
+
// Clear highlight
|
|
545
584
|
restoreObject();
|
|
546
|
-
//
|
|
585
|
+
// Clear reference
|
|
547
586
|
selectedObject = null;
|
|
548
587
|
};
|
|
549
588
|
}
|
|
550
589
|
|
|
551
|
-
// src/utils/ArrowGuide.ts
|
|
552
590
|
/**
|
|
553
|
-
*
|
|
554
|
-
*
|
|
591
|
+
* @file arrowGuide.ts
|
|
592
|
+
* @description
|
|
593
|
+
* Arrow guide effect tool, supports highlighting models and fading other objects.
|
|
594
|
+
*
|
|
595
|
+
* @best-practice
|
|
596
|
+
* - Use `highlight` to focus on specific models.
|
|
597
|
+
* - Automatically manages materials and memory using WeakMap.
|
|
598
|
+
* - Call `dispose` when component unmounts.
|
|
599
|
+
*/
|
|
600
|
+
/**
|
|
601
|
+
* ArrowGuide - Optimized Version
|
|
602
|
+
* Arrow guide effect tool, supports highlighting models and fading other objects.
|
|
555
603
|
*
|
|
556
|
-
*
|
|
557
|
-
* -
|
|
558
|
-
* -
|
|
559
|
-
* -
|
|
560
|
-
* -
|
|
561
|
-
* -
|
|
604
|
+
* Features:
|
|
605
|
+
* - Uses WeakMap for automatic material recycling, preventing memory leaks
|
|
606
|
+
* - Uses AbortController to manage event lifecycle
|
|
607
|
+
* - Adds material reuse mechanism to reuse materials
|
|
608
|
+
* - Improved dispose logic ensuring complete resource release
|
|
609
|
+
* - Adds error handling and boundary checks
|
|
562
610
|
*/
|
|
563
611
|
class ArrowGuide {
|
|
564
612
|
constructor(renderer, camera, scene, options) {
|
|
@@ -573,12 +621,12 @@ class ArrowGuide {
|
|
|
573
621
|
this.clickThreshold = 10;
|
|
574
622
|
this.raycaster = new THREE.Raycaster();
|
|
575
623
|
this.mouse = new THREE.Vector2();
|
|
576
|
-
//
|
|
624
|
+
// Use WeakMap for automatic material recycling (GC friendly)
|
|
577
625
|
this.originalMaterials = new WeakMap();
|
|
578
626
|
this.fadedMaterials = new WeakMap();
|
|
579
|
-
//
|
|
627
|
+
// AbortController for event management
|
|
580
628
|
this.abortController = null;
|
|
581
|
-
//
|
|
629
|
+
// Config: Non-highlight opacity and brightness
|
|
582
630
|
this.fadeOpacity = 0.5;
|
|
583
631
|
this.fadeBrightness = 0.1;
|
|
584
632
|
this.clickThreshold = (_a = options === null || options === void 0 ? void 0 : options.clickThreshold) !== null && _a !== void 0 ? _a : 10;
|
|
@@ -588,30 +636,30 @@ class ArrowGuide {
|
|
|
588
636
|
this.abortController = new AbortController();
|
|
589
637
|
this.initEvents();
|
|
590
638
|
}
|
|
591
|
-
//
|
|
639
|
+
// Tool: Cache original material (first time only)
|
|
592
640
|
cacheOriginalMaterial(mesh) {
|
|
593
641
|
if (!this.originalMaterials.has(mesh)) {
|
|
594
642
|
this.originalMaterials.set(mesh, mesh.material);
|
|
595
643
|
}
|
|
596
644
|
}
|
|
597
|
-
//
|
|
645
|
+
// Tool: Clone a "translucent version" for a material, preserving all maps and parameters
|
|
598
646
|
makeFadedClone(mat) {
|
|
599
647
|
const clone = mat.clone();
|
|
600
648
|
const c = clone;
|
|
601
|
-
//
|
|
649
|
+
// Only modify transparency parameters, do not modify detail maps like map / normalMap / roughnessMap
|
|
602
650
|
c.transparent = true;
|
|
603
651
|
if (typeof c.opacity === 'number')
|
|
604
652
|
c.opacity = this.fadeOpacity;
|
|
605
653
|
if (c.color && c.color.isColor) {
|
|
606
|
-
c.color.multiplyScalar(this.fadeBrightness); //
|
|
654
|
+
c.color.multiplyScalar(this.fadeBrightness); // Darken color overall
|
|
607
655
|
}
|
|
608
|
-
//
|
|
656
|
+
// Common strategy for fluid display behind transparent objects: do not write depth, only test depth
|
|
609
657
|
clone.depthWrite = false;
|
|
610
658
|
clone.depthTest = true;
|
|
611
659
|
clone.needsUpdate = true;
|
|
612
660
|
return clone;
|
|
613
661
|
}
|
|
614
|
-
//
|
|
662
|
+
// Tool: Batch clone "translucent version" for mesh.material (could be array)
|
|
615
663
|
createFadedMaterialFrom(mesh) {
|
|
616
664
|
const orig = mesh.material;
|
|
617
665
|
if (Array.isArray(orig)) {
|
|
@@ -620,7 +668,7 @@ class ArrowGuide {
|
|
|
620
668
|
return this.makeFadedClone(orig);
|
|
621
669
|
}
|
|
622
670
|
/**
|
|
623
|
-
*
|
|
671
|
+
* Set Arrow Mesh
|
|
624
672
|
*/
|
|
625
673
|
setArrowMesh(mesh) {
|
|
626
674
|
this.lxMesh = mesh;
|
|
@@ -636,15 +684,15 @@ class ArrowGuide {
|
|
|
636
684
|
mesh.visible = false;
|
|
637
685
|
}
|
|
638
686
|
catch (error) {
|
|
639
|
-
console.error('ArrowGuide:
|
|
687
|
+
console.error('ArrowGuide: Failed to set arrow material', error);
|
|
640
688
|
}
|
|
641
689
|
}
|
|
642
690
|
/**
|
|
643
|
-
*
|
|
691
|
+
* Highlight specified models
|
|
644
692
|
*/
|
|
645
693
|
highlight(models) {
|
|
646
694
|
if (!models || models.length === 0) {
|
|
647
|
-
console.warn('ArrowGuide:
|
|
695
|
+
console.warn('ArrowGuide: Highlight model list is empty');
|
|
648
696
|
return;
|
|
649
697
|
}
|
|
650
698
|
this.modelBrightArr = models;
|
|
@@ -653,9 +701,9 @@ class ArrowGuide {
|
|
|
653
701
|
this.lxMesh.visible = true;
|
|
654
702
|
this.applyHighlight();
|
|
655
703
|
}
|
|
656
|
-
//
|
|
704
|
+
// Apply highlight effect: Non-highlighted models preserve details -> use "cloned translucent material"
|
|
657
705
|
applyHighlight() {
|
|
658
|
-
//
|
|
706
|
+
// Use Set to improve lookup performance
|
|
659
707
|
const keepMeshes = new Set();
|
|
660
708
|
this.modelBrightArr.forEach(obj => {
|
|
661
709
|
obj.traverse(child => {
|
|
@@ -667,21 +715,21 @@ class ArrowGuide {
|
|
|
667
715
|
this.scene.traverse(obj => {
|
|
668
716
|
if (obj.isMesh) {
|
|
669
717
|
const mesh = obj;
|
|
670
|
-
//
|
|
718
|
+
// Cache original material (for restoration)
|
|
671
719
|
this.cacheOriginalMaterial(mesh);
|
|
672
720
|
if (!keepMeshes.has(mesh)) {
|
|
673
|
-
//
|
|
721
|
+
// Non-highlighted: if no "translucent clone material" generated yet, create one
|
|
674
722
|
if (!this.fadedMaterials.has(mesh)) {
|
|
675
723
|
const faded = this.createFadedMaterialFrom(mesh);
|
|
676
724
|
this.fadedMaterials.set(mesh, faded);
|
|
677
725
|
}
|
|
678
|
-
//
|
|
726
|
+
// Replace with clone material (preserve all maps/normals details)
|
|
679
727
|
const fadedMat = this.fadedMaterials.get(mesh);
|
|
680
728
|
if (fadedMat)
|
|
681
729
|
mesh.material = fadedMat;
|
|
682
730
|
}
|
|
683
731
|
else {
|
|
684
|
-
//
|
|
732
|
+
// Highlighted object: ensure return to original material (avoid leftover from previous highlight)
|
|
685
733
|
const orig = this.originalMaterials.get(mesh);
|
|
686
734
|
if (orig && mesh.material !== orig) {
|
|
687
735
|
mesh.material = orig;
|
|
@@ -692,16 +740,16 @@ class ArrowGuide {
|
|
|
692
740
|
});
|
|
693
741
|
}
|
|
694
742
|
catch (error) {
|
|
695
|
-
console.error('ArrowGuide:
|
|
743
|
+
console.error('ArrowGuide: Failed to apply highlight', error);
|
|
696
744
|
}
|
|
697
745
|
}
|
|
698
|
-
//
|
|
746
|
+
// Restore to original material & dispose clone material
|
|
699
747
|
restore() {
|
|
700
748
|
this.flowActive = false;
|
|
701
749
|
if (this.lxMesh)
|
|
702
750
|
this.lxMesh.visible = false;
|
|
703
751
|
try {
|
|
704
|
-
//
|
|
752
|
+
// Collect all materials to dispose
|
|
705
753
|
const materialsToDispose = [];
|
|
706
754
|
this.scene.traverse(obj => {
|
|
707
755
|
if (obj.isMesh) {
|
|
@@ -711,7 +759,7 @@ class ArrowGuide {
|
|
|
711
759
|
mesh.material = orig;
|
|
712
760
|
mesh.material.needsUpdate = true;
|
|
713
761
|
}
|
|
714
|
-
//
|
|
762
|
+
// Collect faded materials to dispose
|
|
715
763
|
const faded = this.fadedMaterials.get(mesh);
|
|
716
764
|
if (faded) {
|
|
717
765
|
if (Array.isArray(faded)) {
|
|
@@ -723,24 +771,24 @@ class ArrowGuide {
|
|
|
723
771
|
}
|
|
724
772
|
}
|
|
725
773
|
});
|
|
726
|
-
//
|
|
774
|
+
// Batch dispose materials (do not touch texture resources)
|
|
727
775
|
materialsToDispose.forEach(mat => {
|
|
728
776
|
try {
|
|
729
777
|
mat.dispose();
|
|
730
778
|
}
|
|
731
779
|
catch (error) {
|
|
732
|
-
console.error('ArrowGuide:
|
|
780
|
+
console.error('ArrowGuide: Failed to dispose material', error);
|
|
733
781
|
}
|
|
734
782
|
});
|
|
735
|
-
//
|
|
783
|
+
// Create new WeakMap (equivalent to clearing)
|
|
736
784
|
this.fadedMaterials = new WeakMap();
|
|
737
785
|
}
|
|
738
786
|
catch (error) {
|
|
739
|
-
console.error('ArrowGuide:
|
|
787
|
+
console.error('ArrowGuide: Failed to restore material', error);
|
|
740
788
|
}
|
|
741
789
|
}
|
|
742
790
|
/**
|
|
743
|
-
*
|
|
791
|
+
* Animation update (called every frame)
|
|
744
792
|
*/
|
|
745
793
|
animate() {
|
|
746
794
|
if (!this.flowActive || !this.lxMesh)
|
|
@@ -754,16 +802,16 @@ class ArrowGuide {
|
|
|
754
802
|
}
|
|
755
803
|
}
|
|
756
804
|
catch (error) {
|
|
757
|
-
console.error('ArrowGuide:
|
|
805
|
+
console.error('ArrowGuide: Animation update failed', error);
|
|
758
806
|
}
|
|
759
807
|
}
|
|
760
808
|
/**
|
|
761
|
-
*
|
|
809
|
+
* Initialize event listeners
|
|
762
810
|
*/
|
|
763
811
|
initEvents() {
|
|
764
812
|
const dom = this.renderer.domElement;
|
|
765
813
|
const signal = this.abortController.signal;
|
|
766
|
-
//
|
|
814
|
+
// Use AbortController signal to automatically manage event lifecycle
|
|
767
815
|
dom.addEventListener('pointerdown', (e) => {
|
|
768
816
|
this.pointerDownPos.set(e.clientX, e.clientY);
|
|
769
817
|
}, { signal });
|
|
@@ -771,7 +819,7 @@ class ArrowGuide {
|
|
|
771
819
|
const dx = Math.abs(e.clientX - this.pointerDownPos.x);
|
|
772
820
|
const dy = Math.abs(e.clientY - this.pointerDownPos.y);
|
|
773
821
|
if (dx > this.clickThreshold || dy > this.clickThreshold)
|
|
774
|
-
return; //
|
|
822
|
+
return; // Dragging
|
|
775
823
|
const rect = dom.getBoundingClientRect();
|
|
776
824
|
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
777
825
|
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
@@ -785,21 +833,21 @@ class ArrowGuide {
|
|
|
785
833
|
return true;
|
|
786
834
|
});
|
|
787
835
|
if (filtered.length === 0)
|
|
788
|
-
this.restore(); //
|
|
836
|
+
this.restore(); // Click blank space to restore
|
|
789
837
|
}, { signal });
|
|
790
838
|
}
|
|
791
839
|
/**
|
|
792
|
-
*
|
|
840
|
+
* Dispose all resources
|
|
793
841
|
*/
|
|
794
842
|
dispose() {
|
|
795
|
-
//
|
|
843
|
+
// Restore materials first
|
|
796
844
|
this.restore();
|
|
797
|
-
//
|
|
845
|
+
// Unbind all events at once using AbortController
|
|
798
846
|
if (this.abortController) {
|
|
799
847
|
this.abortController.abort();
|
|
800
848
|
this.abortController = null;
|
|
801
849
|
}
|
|
802
|
-
//
|
|
850
|
+
// Clear references
|
|
803
851
|
this.modelBrightArr = [];
|
|
804
852
|
this.lxMesh = null;
|
|
805
853
|
this.fadedMaterials = new WeakMap();
|
|
@@ -808,50 +856,59 @@ class ArrowGuide {
|
|
|
808
856
|
}
|
|
809
857
|
}
|
|
810
858
|
|
|
811
|
-
// utils/LiquidFillerGroup.ts
|
|
812
859
|
/**
|
|
813
|
-
*
|
|
814
|
-
*
|
|
860
|
+
* @file liquidFiller.ts
|
|
861
|
+
* @description
|
|
862
|
+
* Liquid filling effect for single or multiple models using local clipping planes.
|
|
815
863
|
*
|
|
816
|
-
*
|
|
817
|
-
* -
|
|
818
|
-
* -
|
|
819
|
-
* -
|
|
820
|
-
|
|
821
|
-
|
|
864
|
+
* @best-practice
|
|
865
|
+
* - Use `fillTo` to animate liquid level.
|
|
866
|
+
* - Supports multiple independent liquid levels.
|
|
867
|
+
* - Call `dispose` to clean up resources and event listeners.
|
|
868
|
+
*/
|
|
869
|
+
/**
|
|
870
|
+
* LiquidFillerGroup - Optimized
|
|
871
|
+
* Supports single or multi-model liquid level animation with independent color control.
|
|
872
|
+
*
|
|
873
|
+
* Features:
|
|
874
|
+
* - Uses renderer.domElement instead of window events
|
|
875
|
+
* - Uses AbortController to manage event lifecycle
|
|
876
|
+
* - Adds error handling and boundary checks
|
|
877
|
+
* - Optimized animation management to prevent memory leaks
|
|
878
|
+
* - Comprehensive resource disposal logic
|
|
822
879
|
*/
|
|
823
880
|
class LiquidFillerGroup {
|
|
824
881
|
/**
|
|
825
|
-
*
|
|
826
|
-
* @param models
|
|
827
|
-
* @param scene
|
|
828
|
-
* @param camera
|
|
829
|
-
* @param renderer
|
|
830
|
-
* @param defaultOptions
|
|
831
|
-
* @param clickThreshold
|
|
882
|
+
* Constructor
|
|
883
|
+
* @param models Single or multiple THREE.Object3D
|
|
884
|
+
* @param scene Scene
|
|
885
|
+
* @param camera Camera
|
|
886
|
+
* @param renderer Renderer
|
|
887
|
+
* @param defaultOptions Default liquid options
|
|
888
|
+
* @param clickThreshold Click threshold in pixels
|
|
832
889
|
*/
|
|
833
890
|
constructor(models, scene, camera, renderer, defaultOptions, clickThreshold = 10) {
|
|
834
891
|
this.items = [];
|
|
835
892
|
this.raycaster = new THREE.Raycaster();
|
|
836
893
|
this.pointerDownPos = new THREE.Vector2();
|
|
837
894
|
this.clickThreshold = 10;
|
|
838
|
-
this.abortController = null; //
|
|
839
|
-
/** pointerdown
|
|
895
|
+
this.abortController = null; // Event manager
|
|
896
|
+
/** pointerdown record position */
|
|
840
897
|
this.handlePointerDown = (event) => {
|
|
841
898
|
this.pointerDownPos.set(event.clientX, event.clientY);
|
|
842
899
|
};
|
|
843
|
-
/** pointerup
|
|
900
|
+
/** pointerup check click blank, restore original material */
|
|
844
901
|
this.handlePointerUp = (event) => {
|
|
845
902
|
const dx = event.clientX - this.pointerDownPos.x;
|
|
846
903
|
const dy = event.clientY - this.pointerDownPos.y;
|
|
847
904
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
848
905
|
if (distance > this.clickThreshold)
|
|
849
|
-
return; //
|
|
850
|
-
//
|
|
906
|
+
return; // Do not trigger on drag
|
|
907
|
+
// Use renderer.domElement actual size
|
|
851
908
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
852
909
|
const pointerNDC = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
|
|
853
910
|
this.raycaster.setFromCamera(pointerNDC, this.camera);
|
|
854
|
-
//
|
|
911
|
+
// Click blank -> Restore all models
|
|
855
912
|
const intersectsAny = this.items.some(item => this.raycaster.intersectObject(item.model, true).length > 0);
|
|
856
913
|
if (!intersectsAny) {
|
|
857
914
|
this.restoreAll();
|
|
@@ -861,7 +918,7 @@ class LiquidFillerGroup {
|
|
|
861
918
|
this.camera = camera;
|
|
862
919
|
this.renderer = renderer;
|
|
863
920
|
this.clickThreshold = clickThreshold;
|
|
864
|
-
//
|
|
921
|
+
// Create AbortController for event management
|
|
865
922
|
this.abortController = new AbortController();
|
|
866
923
|
const modelArray = Array.isArray(models) ? models : [models];
|
|
867
924
|
modelArray.forEach(model => {
|
|
@@ -872,7 +929,7 @@ class LiquidFillerGroup {
|
|
|
872
929
|
opacity: (_b = defaultOptions === null || defaultOptions === void 0 ? void 0 : defaultOptions.opacity) !== null && _b !== void 0 ? _b : 0.6,
|
|
873
930
|
speed: (_c = defaultOptions === null || defaultOptions === void 0 ? void 0 : defaultOptions.speed) !== null && _c !== void 0 ? _c : 0.05,
|
|
874
931
|
};
|
|
875
|
-
//
|
|
932
|
+
// Save original materials
|
|
876
933
|
const originalMaterials = new Map();
|
|
877
934
|
model.traverse(obj => {
|
|
878
935
|
if (obj.isMesh) {
|
|
@@ -880,12 +937,12 @@ class LiquidFillerGroup {
|
|
|
880
937
|
originalMaterials.set(mesh, mesh.material);
|
|
881
938
|
}
|
|
882
939
|
});
|
|
883
|
-
//
|
|
940
|
+
// Boundary check: ensure there are materials to save
|
|
884
941
|
if (originalMaterials.size === 0) {
|
|
885
|
-
console.warn('LiquidFillerGroup:
|
|
942
|
+
console.warn('LiquidFillerGroup: Model has no Mesh objects', model);
|
|
886
943
|
return;
|
|
887
944
|
}
|
|
888
|
-
//
|
|
945
|
+
// Apply faded wireframe material
|
|
889
946
|
model.traverse(obj => {
|
|
890
947
|
if (obj.isMesh) {
|
|
891
948
|
const mesh = obj;
|
|
@@ -897,7 +954,7 @@ class LiquidFillerGroup {
|
|
|
897
954
|
});
|
|
898
955
|
}
|
|
899
956
|
});
|
|
900
|
-
//
|
|
957
|
+
// Create liquid Mesh
|
|
901
958
|
const geometries = [];
|
|
902
959
|
model.traverse(obj => {
|
|
903
960
|
if (obj.isMesh) {
|
|
@@ -908,12 +965,12 @@ class LiquidFillerGroup {
|
|
|
908
965
|
}
|
|
909
966
|
});
|
|
910
967
|
if (geometries.length === 0) {
|
|
911
|
-
console.warn('LiquidFillerGroup:
|
|
968
|
+
console.warn('LiquidFillerGroup: Model has no geometries', model);
|
|
912
969
|
return;
|
|
913
970
|
}
|
|
914
971
|
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries, false);
|
|
915
972
|
if (!mergedGeometry) {
|
|
916
|
-
console.error('LiquidFillerGroup:
|
|
973
|
+
console.error('LiquidFillerGroup: Failed to merge geometries', model);
|
|
917
974
|
return;
|
|
918
975
|
}
|
|
919
976
|
const material = new THREE.MeshPhongMaterial({
|
|
@@ -924,7 +981,7 @@ class LiquidFillerGroup {
|
|
|
924
981
|
});
|
|
925
982
|
const liquidMesh = new THREE.Mesh(mergedGeometry, material);
|
|
926
983
|
this.scene.add(liquidMesh);
|
|
927
|
-
//
|
|
984
|
+
// Set clippingPlane
|
|
928
985
|
const clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
|
929
986
|
const mat = liquidMesh.material;
|
|
930
987
|
mat.clippingPlanes = [clipPlane];
|
|
@@ -935,41 +992,41 @@ class LiquidFillerGroup {
|
|
|
935
992
|
clipPlane,
|
|
936
993
|
originalMaterials,
|
|
937
994
|
options,
|
|
938
|
-
animationId: null //
|
|
995
|
+
animationId: null // Initialize animation ID
|
|
939
996
|
});
|
|
940
997
|
}
|
|
941
998
|
catch (error) {
|
|
942
|
-
console.error('LiquidFillerGroup:
|
|
999
|
+
console.error('LiquidFillerGroup: Failed to initialize model', model, error);
|
|
943
1000
|
}
|
|
944
1001
|
});
|
|
945
|
-
//
|
|
1002
|
+
// Use renderer.domElement instead of window, use AbortController signal
|
|
946
1003
|
const signal = this.abortController.signal;
|
|
947
1004
|
this.renderer.domElement.addEventListener('pointerdown', this.handlePointerDown, { signal });
|
|
948
1005
|
this.renderer.domElement.addEventListener('pointerup', this.handlePointerUp, { signal });
|
|
949
1006
|
}
|
|
950
1007
|
/**
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1008
|
+
* Set liquid level
|
|
1009
|
+
* @param models Single model or array of models
|
|
1010
|
+
* @param percent Liquid level percentage 0~1
|
|
1011
|
+
*/
|
|
955
1012
|
fillTo(models, percent) {
|
|
956
|
-
//
|
|
1013
|
+
// Boundary check
|
|
957
1014
|
if (percent < 0 || percent > 1) {
|
|
958
|
-
console.warn('LiquidFillerGroup: percent
|
|
1015
|
+
console.warn('LiquidFillerGroup: percent must be between 0 and 1', percent);
|
|
959
1016
|
percent = Math.max(0, Math.min(1, percent));
|
|
960
1017
|
}
|
|
961
1018
|
const modelArray = Array.isArray(models) ? models : [models];
|
|
962
1019
|
modelArray.forEach(model => {
|
|
963
1020
|
const item = this.items.find(i => i.model === model);
|
|
964
1021
|
if (!item) {
|
|
965
|
-
console.warn('LiquidFillerGroup:
|
|
1022
|
+
console.warn('LiquidFillerGroup: Model not found', model);
|
|
966
1023
|
return;
|
|
967
1024
|
}
|
|
968
1025
|
if (!item.liquidMesh) {
|
|
969
|
-
console.warn('LiquidFillerGroup: liquidMesh
|
|
1026
|
+
console.warn('LiquidFillerGroup: liquidMesh already disposed', model);
|
|
970
1027
|
return;
|
|
971
1028
|
}
|
|
972
|
-
//
|
|
1029
|
+
// Cancel previous animation
|
|
973
1030
|
if (item.animationId !== null) {
|
|
974
1031
|
cancelAnimationFrame(item.animationId);
|
|
975
1032
|
item.animationId = null;
|
|
@@ -997,14 +1054,14 @@ class LiquidFillerGroup {
|
|
|
997
1054
|
animate();
|
|
998
1055
|
}
|
|
999
1056
|
catch (error) {
|
|
1000
|
-
console.error('LiquidFillerGroup: fillTo
|
|
1057
|
+
console.error('LiquidFillerGroup: fillTo execution failed', model, error);
|
|
1001
1058
|
}
|
|
1002
1059
|
});
|
|
1003
1060
|
}
|
|
1004
|
-
/**
|
|
1061
|
+
/** Set multiple model levels, percentList corresponds to items order */
|
|
1005
1062
|
fillToAll(percentList) {
|
|
1006
1063
|
if (percentList.length !== this.items.length) {
|
|
1007
|
-
console.warn(`LiquidFillerGroup: percentList
|
|
1064
|
+
console.warn(`LiquidFillerGroup: percentList length (${percentList.length}) does not match items length (${this.items.length})`);
|
|
1008
1065
|
}
|
|
1009
1066
|
percentList.forEach((p, idx) => {
|
|
1010
1067
|
if (idx < this.items.length) {
|
|
@@ -1012,17 +1069,17 @@ class LiquidFillerGroup {
|
|
|
1012
1069
|
}
|
|
1013
1070
|
});
|
|
1014
1071
|
}
|
|
1015
|
-
/**
|
|
1072
|
+
/** Restore single model original material and remove liquid */
|
|
1016
1073
|
restore(model) {
|
|
1017
1074
|
const item = this.items.find(i => i.model === model);
|
|
1018
1075
|
if (!item)
|
|
1019
1076
|
return;
|
|
1020
|
-
//
|
|
1077
|
+
// Cancel animation
|
|
1021
1078
|
if (item.animationId !== null) {
|
|
1022
1079
|
cancelAnimationFrame(item.animationId);
|
|
1023
1080
|
item.animationId = null;
|
|
1024
1081
|
}
|
|
1025
|
-
//
|
|
1082
|
+
// Restore original material
|
|
1026
1083
|
item.model.traverse(obj => {
|
|
1027
1084
|
if (obj.isMesh) {
|
|
1028
1085
|
const mesh = obj;
|
|
@@ -1031,7 +1088,7 @@ class LiquidFillerGroup {
|
|
|
1031
1088
|
mesh.material = original;
|
|
1032
1089
|
}
|
|
1033
1090
|
});
|
|
1034
|
-
//
|
|
1091
|
+
// Dispose liquid Mesh
|
|
1035
1092
|
if (item.liquidMesh) {
|
|
1036
1093
|
this.scene.remove(item.liquidMesh);
|
|
1037
1094
|
item.liquidMesh.geometry.dispose();
|
|
@@ -1044,50 +1101,60 @@ class LiquidFillerGroup {
|
|
|
1044
1101
|
item.liquidMesh = null;
|
|
1045
1102
|
}
|
|
1046
1103
|
}
|
|
1047
|
-
/**
|
|
1104
|
+
/** Restore all models */
|
|
1048
1105
|
restoreAll() {
|
|
1049
1106
|
this.items.forEach(item => this.restore(item.model));
|
|
1050
1107
|
}
|
|
1051
|
-
/**
|
|
1108
|
+
/** Dispose method, release events and resources */
|
|
1052
1109
|
dispose() {
|
|
1053
|
-
//
|
|
1110
|
+
// Restore all models first
|
|
1054
1111
|
this.restoreAll();
|
|
1055
|
-
//
|
|
1112
|
+
// Unbind all events at once using AbortController
|
|
1056
1113
|
if (this.abortController) {
|
|
1057
1114
|
this.abortController.abort();
|
|
1058
1115
|
this.abortController = null;
|
|
1059
1116
|
}
|
|
1060
|
-
//
|
|
1117
|
+
// Clear items
|
|
1061
1118
|
this.items.length = 0;
|
|
1062
1119
|
}
|
|
1063
1120
|
}
|
|
1064
1121
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1122
|
+
/**
|
|
1123
|
+
* @file followModels.ts
|
|
1124
|
+
* @description
|
|
1125
|
+
* Camera utility to automatically follow and focus on 3D models.
|
|
1126
|
+
* It smoothly moves the camera to an optimal viewing position relative to the target object(s).
|
|
1127
|
+
*
|
|
1128
|
+
* @best-practice
|
|
1129
|
+
* - Use `followModels` to focus on a newly selected object.
|
|
1130
|
+
* - Call `cancelFollow` before starting a new manual camera interaction if needed.
|
|
1131
|
+
* - Adjust `padding` to control how tight the camera framing is.
|
|
1132
|
+
*/
|
|
1133
|
+
// Use WeakMap to track animations, allowing for cancellation
|
|
1067
1134
|
const _animationMap = new WeakMap();
|
|
1068
1135
|
/**
|
|
1069
|
-
*
|
|
1136
|
+
* Recommended camera angles for quick selection of common views
|
|
1070
1137
|
*/
|
|
1071
1138
|
const FOLLOW_ANGLES = {
|
|
1072
|
-
/**
|
|
1139
|
+
/** Isometric view (default) - suitable for architecture, mechanical equipment */
|
|
1073
1140
|
ISOMETRIC: { azimuth: Math.PI / 4, elevation: Math.PI / 4 },
|
|
1074
|
-
/**
|
|
1141
|
+
/** Front view - suitable for frontal display, UI alignment */
|
|
1075
1142
|
FRONT: { azimuth: 0, elevation: 0 },
|
|
1076
|
-
/**
|
|
1143
|
+
/** Right view - suitable for mechanical sections, side inspection */
|
|
1077
1144
|
RIGHT: { azimuth: Math.PI / 2, elevation: 0 },
|
|
1078
|
-
/**
|
|
1145
|
+
/** Left view */
|
|
1079
1146
|
LEFT: { azimuth: -Math.PI / 2, elevation: 0 },
|
|
1080
|
-
/**
|
|
1147
|
+
/** Back view */
|
|
1081
1148
|
BACK: { azimuth: Math.PI, elevation: 0 },
|
|
1082
|
-
/**
|
|
1149
|
+
/** Top view - suitable for maps, layout display */
|
|
1083
1150
|
TOP: { azimuth: 0, elevation: Math.PI / 2 },
|
|
1084
|
-
/**
|
|
1151
|
+
/** Low angle view - suitable for vehicles, characters near the ground */
|
|
1085
1152
|
LOW_ANGLE: { azimuth: Math.PI / 4, elevation: Math.PI / 6 },
|
|
1086
|
-
/**
|
|
1153
|
+
/** High angle view - suitable for bird's eye view, panoramic browsing */
|
|
1087
1154
|
HIGH_ANGLE: { azimuth: Math.PI / 4, elevation: Math.PI / 3 }
|
|
1088
1155
|
};
|
|
1089
1156
|
/**
|
|
1090
|
-
*
|
|
1157
|
+
* Collection of easing functions
|
|
1091
1158
|
*/
|
|
1092
1159
|
const EASING_FUNCTIONS = {
|
|
1093
1160
|
linear: (t) => t,
|
|
@@ -1096,20 +1163,21 @@ const EASING_FUNCTIONS = {
|
|
|
1096
1163
|
easeIn: (t) => t * t * t
|
|
1097
1164
|
};
|
|
1098
1165
|
/**
|
|
1099
|
-
*
|
|
1166
|
+
* Automatically moves the camera to a diagonal position relative to the target,
|
|
1167
|
+
* ensuring the target is within the field of view (smooth transition).
|
|
1100
1168
|
*
|
|
1101
|
-
*
|
|
1102
|
-
* -
|
|
1103
|
-
* -
|
|
1104
|
-
* -
|
|
1105
|
-
* - WeakMap
|
|
1106
|
-
* -
|
|
1169
|
+
* Features:
|
|
1170
|
+
* - Supports multiple easing functions
|
|
1171
|
+
* - Adds progress callback
|
|
1172
|
+
* - Supports animation cancellation
|
|
1173
|
+
* - Uses WeakMap to track and prevent memory leaks
|
|
1174
|
+
* - Robust error handling
|
|
1107
1175
|
*/
|
|
1108
1176
|
function followModels(camera, targets, options = {}) {
|
|
1109
1177
|
var _a, _b, _c, _d, _e, _f;
|
|
1110
|
-
//
|
|
1178
|
+
// Cancel previous animation
|
|
1111
1179
|
cancelFollow(camera);
|
|
1112
|
-
//
|
|
1180
|
+
// Boundary check
|
|
1113
1181
|
const arr = [];
|
|
1114
1182
|
if (!targets)
|
|
1115
1183
|
return Promise.resolve();
|
|
@@ -1118,15 +1186,15 @@ function followModels(camera, targets, options = {}) {
|
|
|
1118
1186
|
else
|
|
1119
1187
|
arr.push(targets);
|
|
1120
1188
|
if (arr.length === 0) {
|
|
1121
|
-
console.warn('followModels:
|
|
1189
|
+
console.warn('followModels: Target object is empty');
|
|
1122
1190
|
return Promise.resolve();
|
|
1123
1191
|
}
|
|
1124
1192
|
try {
|
|
1125
1193
|
const box = new THREE.Box3();
|
|
1126
1194
|
arr.forEach((o) => box.expandByObject(o));
|
|
1127
|
-
//
|
|
1195
|
+
// Check bounding box validity
|
|
1128
1196
|
if (!isFinite(box.min.x) || !isFinite(box.max.x)) {
|
|
1129
|
-
console.warn('followModels:
|
|
1197
|
+
console.warn('followModels: Failed to calculate bounding box');
|
|
1130
1198
|
return Promise.resolve();
|
|
1131
1199
|
}
|
|
1132
1200
|
const sphere = new THREE.Sphere();
|
|
@@ -1142,7 +1210,7 @@ function followModels(camera, targets, options = {}) {
|
|
|
1142
1210
|
const elevation = (_e = options.elevation) !== null && _e !== void 0 ? _e : Math.PI / 4;
|
|
1143
1211
|
const easing = (_f = options.easing) !== null && _f !== void 0 ? _f : 'easeOut';
|
|
1144
1212
|
const onProgress = options.onProgress;
|
|
1145
|
-
//
|
|
1213
|
+
// Get easing function
|
|
1146
1214
|
const easingFn = EASING_FUNCTIONS[easing] || EASING_FUNCTIONS.easeOut;
|
|
1147
1215
|
let distance = 10;
|
|
1148
1216
|
if (camera.isPerspectiveCamera) {
|
|
@@ -1162,7 +1230,7 @@ function followModels(camera, targets, options = {}) {
|
|
|
1162
1230
|
else {
|
|
1163
1231
|
distance = camera.position.distanceTo(center);
|
|
1164
1232
|
}
|
|
1165
|
-
//
|
|
1233
|
+
// Calculate direction based on azimuth / elevation
|
|
1166
1234
|
const hx = Math.sin(azimuth);
|
|
1167
1235
|
const hz = Math.cos(azimuth);
|
|
1168
1236
|
const dir = new THREE.Vector3(hx * Math.cos(elevation), Math.sin(elevation), hz * Math.cos(elevation)).normalize();
|
|
@@ -1175,7 +1243,6 @@ function followModels(camera, targets, options = {}) {
|
|
|
1175
1243
|
const startTime = performance.now();
|
|
1176
1244
|
return new Promise((resolve) => {
|
|
1177
1245
|
const step = (now) => {
|
|
1178
|
-
var _a;
|
|
1179
1246
|
const elapsed = now - startTime;
|
|
1180
1247
|
const t = Math.min(1, duration > 0 ? elapsed / duration : 1);
|
|
1181
1248
|
const k = easingFn(t);
|
|
@@ -1189,14 +1256,16 @@ function followModels(camera, targets, options = {}) {
|
|
|
1189
1256
|
else {
|
|
1190
1257
|
camera.lookAt(endTarget);
|
|
1191
1258
|
}
|
|
1192
|
-
(
|
|
1193
|
-
|
|
1259
|
+
if (camera.updateProjectionMatrix) {
|
|
1260
|
+
camera.updateProjectionMatrix();
|
|
1261
|
+
}
|
|
1262
|
+
// Call progress callback
|
|
1194
1263
|
if (onProgress) {
|
|
1195
1264
|
try {
|
|
1196
1265
|
onProgress(t);
|
|
1197
1266
|
}
|
|
1198
1267
|
catch (error) {
|
|
1199
|
-
console.error('followModels:
|
|
1268
|
+
console.error('followModels: Progress callback error', error);
|
|
1200
1269
|
}
|
|
1201
1270
|
}
|
|
1202
1271
|
if (t < 1) {
|
|
@@ -1222,12 +1291,12 @@ function followModels(camera, targets, options = {}) {
|
|
|
1222
1291
|
});
|
|
1223
1292
|
}
|
|
1224
1293
|
catch (error) {
|
|
1225
|
-
console.error('followModels:
|
|
1294
|
+
console.error('followModels: Execution failed', error);
|
|
1226
1295
|
return Promise.reject(error);
|
|
1227
1296
|
}
|
|
1228
1297
|
}
|
|
1229
1298
|
/**
|
|
1230
|
-
*
|
|
1299
|
+
* Cancel the camera follow animation
|
|
1231
1300
|
*/
|
|
1232
1301
|
function cancelFollow(camera) {
|
|
1233
1302
|
const rafId = _animationMap.get(camera);
|
|
@@ -1237,42 +1306,50 @@ function cancelFollow(camera) {
|
|
|
1237
1306
|
}
|
|
1238
1307
|
}
|
|
1239
1308
|
|
|
1240
|
-
// src/utils/setView.ts - 优化版
|
|
1241
1309
|
/**
|
|
1242
|
-
*
|
|
1310
|
+
* @file setView.ts
|
|
1311
|
+
* @description
|
|
1312
|
+
* Utility to smoothly transition the camera to preset views (Front, Back, Top, Isometric, etc.).
|
|
1313
|
+
*
|
|
1314
|
+
* @best-practice
|
|
1315
|
+
* - Use `setView` for UI buttons that switch camera angles.
|
|
1316
|
+
* - Leverage `ViewPresets` for readable code when using standard views.
|
|
1317
|
+
*/
|
|
1318
|
+
/**
|
|
1319
|
+
* Smoothly switches the camera to the optimal angle for the model.
|
|
1243
1320
|
*
|
|
1244
|
-
*
|
|
1245
|
-
* -
|
|
1246
|
-
* -
|
|
1247
|
-
* -
|
|
1248
|
-
* -
|
|
1249
|
-
* -
|
|
1321
|
+
* Features:
|
|
1322
|
+
* - Reuses followModels logic to avoid code duplication
|
|
1323
|
+
* - Supports more angles
|
|
1324
|
+
* - Enhanced configuration options
|
|
1325
|
+
* - Returns Promise to support chaining
|
|
1326
|
+
* - Supports animation cancellation
|
|
1250
1327
|
*
|
|
1251
|
-
* @param camera THREE.PerspectiveCamera
|
|
1252
|
-
* @param controls OrbitControls
|
|
1253
|
-
* @param targetObj THREE.Object3D
|
|
1254
|
-
* @param position
|
|
1255
|
-
* @param options
|
|
1328
|
+
* @param camera THREE.PerspectiveCamera instance
|
|
1329
|
+
* @param controls OrbitControls instance
|
|
1330
|
+
* @param targetObj THREE.Object3D model object
|
|
1331
|
+
* @param position View position
|
|
1332
|
+
* @param options Configuration options
|
|
1256
1333
|
* @returns Promise<void>
|
|
1257
1334
|
*/
|
|
1258
1335
|
function setView(camera, controls, targetObj, position = 'front', options = {}) {
|
|
1259
1336
|
const { distanceFactor = 0.8, duration = 1000, easing = 'easeInOut', onProgress } = options;
|
|
1260
|
-
//
|
|
1337
|
+
// Boundary check
|
|
1261
1338
|
if (!targetObj) {
|
|
1262
|
-
console.warn('setView:
|
|
1339
|
+
console.warn('setView: Target object is empty');
|
|
1263
1340
|
return Promise.reject(new Error('Target object is required'));
|
|
1264
1341
|
}
|
|
1265
1342
|
try {
|
|
1266
|
-
//
|
|
1343
|
+
// Calculate bounding box
|
|
1267
1344
|
const box = new THREE.Box3().setFromObject(targetObj);
|
|
1268
1345
|
if (!isFinite(box.min.x)) {
|
|
1269
|
-
console.warn('setView:
|
|
1346
|
+
console.warn('setView: Failed to calculate bounding box');
|
|
1270
1347
|
return Promise.reject(new Error('Invalid bounding box'));
|
|
1271
1348
|
}
|
|
1272
1349
|
const center = box.getCenter(new THREE.Vector3());
|
|
1273
1350
|
const size = box.getSize(new THREE.Vector3());
|
|
1274
1351
|
const maxSize = Math.max(size.x, size.y, size.z);
|
|
1275
|
-
//
|
|
1352
|
+
// Use mapping table for creating view angles
|
|
1276
1353
|
const viewAngles = {
|
|
1277
1354
|
'front': { azimuth: 0, elevation: 0 },
|
|
1278
1355
|
'back': { azimuth: Math.PI, elevation: 0 },
|
|
@@ -1283,7 +1360,7 @@ function setView(camera, controls, targetObj, position = 'front', options = {})
|
|
|
1283
1360
|
'iso': { azimuth: Math.PI / 4, elevation: Math.PI / 4 }
|
|
1284
1361
|
};
|
|
1285
1362
|
const angle = viewAngles[position] || viewAngles.front;
|
|
1286
|
-
//
|
|
1363
|
+
// Reuse followModels to avoid code duplication
|
|
1287
1364
|
return followModels(camera, targetObj, {
|
|
1288
1365
|
duration,
|
|
1289
1366
|
padding: distanceFactor,
|
|
@@ -1295,30 +1372,30 @@ function setView(camera, controls, targetObj, position = 'front', options = {})
|
|
|
1295
1372
|
});
|
|
1296
1373
|
}
|
|
1297
1374
|
catch (error) {
|
|
1298
|
-
console.error('setView:
|
|
1375
|
+
console.error('setView: Execution failed', error);
|
|
1299
1376
|
return Promise.reject(error);
|
|
1300
1377
|
}
|
|
1301
1378
|
}
|
|
1302
1379
|
/**
|
|
1303
|
-
*
|
|
1380
|
+
* Cancel view switch animation
|
|
1304
1381
|
*/
|
|
1305
1382
|
function cancelSetView(camera) {
|
|
1306
1383
|
cancelFollow(camera);
|
|
1307
1384
|
}
|
|
1308
1385
|
/**
|
|
1309
|
-
*
|
|
1386
|
+
* Preset view shortcut methods
|
|
1310
1387
|
*/
|
|
1311
1388
|
const ViewPresets = {
|
|
1312
1389
|
/**
|
|
1313
|
-
*
|
|
1390
|
+
* Front View
|
|
1314
1391
|
*/
|
|
1315
1392
|
front: (camera, controls, target, options) => setView(camera, controls, target, 'front', options),
|
|
1316
1393
|
/**
|
|
1317
|
-
*
|
|
1394
|
+
* Isometric View
|
|
1318
1395
|
*/
|
|
1319
1396
|
isometric: (camera, controls, target, options) => setView(camera, controls, target, 'iso', options),
|
|
1320
1397
|
/**
|
|
1321
|
-
*
|
|
1398
|
+
* Top View
|
|
1322
1399
|
*/
|
|
1323
1400
|
top: (camera, controls, target, options) => setView(camera, controls, target, 'top', options)
|
|
1324
1401
|
};
|
|
@@ -1355,6 +1432,16 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
|
|
|
1355
1432
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
1356
1433
|
};
|
|
1357
1434
|
|
|
1435
|
+
/**
|
|
1436
|
+
* @file modelLoader.ts
|
|
1437
|
+
* @description
|
|
1438
|
+
* Utility to load 3D models (GLTF, FBX, OBJ, PLY, STL) from URLs.
|
|
1439
|
+
*
|
|
1440
|
+
* @best-practice
|
|
1441
|
+
* - Use `loadModelByUrl` for a unified loading interface.
|
|
1442
|
+
* - Supports Draco compression and KTX2 textures for GLTF.
|
|
1443
|
+
* - Includes optimization options like geometry merging and texture downscaling.
|
|
1444
|
+
*/
|
|
1358
1445
|
const DEFAULT_OPTIONS$1 = {
|
|
1359
1446
|
useKTX2: false,
|
|
1360
1447
|
mergeGeometries: false,
|
|
@@ -1362,12 +1449,12 @@ const DEFAULT_OPTIONS$1 = {
|
|
|
1362
1449
|
useSimpleMaterials: false,
|
|
1363
1450
|
skipSkinned: true,
|
|
1364
1451
|
};
|
|
1365
|
-
/**
|
|
1452
|
+
/** Automatically determine which options to enable based on extension (smart judgment) */
|
|
1366
1453
|
function normalizeOptions(url, opts) {
|
|
1367
1454
|
const ext = (url.split('.').pop() || '').toLowerCase();
|
|
1368
1455
|
const merged = Object.assign(Object.assign({}, DEFAULT_OPTIONS$1), opts);
|
|
1369
1456
|
if (ext === 'gltf' || ext === 'glb') {
|
|
1370
|
-
// gltf/glb
|
|
1457
|
+
// gltf/glb defaults to trying draco/ktx2 if user didn't specify
|
|
1371
1458
|
if (merged.dracoDecoderPath === undefined)
|
|
1372
1459
|
merged.dracoDecoderPath = '/draco/';
|
|
1373
1460
|
if (merged.useKTX2 === undefined)
|
|
@@ -1376,7 +1463,7 @@ function normalizeOptions(url, opts) {
|
|
|
1376
1463
|
merged.ktx2TranscoderPath = '/basis/';
|
|
1377
1464
|
}
|
|
1378
1465
|
else {
|
|
1379
|
-
// fbx/obj/ply/stl
|
|
1466
|
+
// fbx/obj/ply/stl etc. do not need draco/ktx2
|
|
1380
1467
|
merged.dracoDecoderPath = null;
|
|
1381
1468
|
merged.ktx2TranscoderPath = null;
|
|
1382
1469
|
merged.useKTX2 = false;
|
|
@@ -1432,8 +1519,8 @@ function loadModelByUrl(url_1) {
|
|
|
1432
1519
|
var _a;
|
|
1433
1520
|
if (ext === 'gltf' || ext === 'glb') {
|
|
1434
1521
|
const sceneObj = res.scene || res;
|
|
1435
|
-
// ---
|
|
1436
|
-
//
|
|
1522
|
+
// --- Critical: Expose animations to scene.userData (or scene.animations) ---
|
|
1523
|
+
// So the caller can access clips simply by getting sceneObj.userData.animations
|
|
1437
1524
|
sceneObj.userData = (sceneObj === null || sceneObj === void 0 ? void 0 : sceneObj.userData) || {};
|
|
1438
1525
|
sceneObj.userData.animations = (_a = res.animations) !== null && _a !== void 0 ? _a : [];
|
|
1439
1526
|
resolve(sceneObj);
|
|
@@ -1443,7 +1530,7 @@ function loadModelByUrl(url_1) {
|
|
|
1443
1530
|
}
|
|
1444
1531
|
}, undefined, (err) => reject(err));
|
|
1445
1532
|
});
|
|
1446
|
-
//
|
|
1533
|
+
// Optimize
|
|
1447
1534
|
object.traverse((child) => {
|
|
1448
1535
|
var _a, _b, _c;
|
|
1449
1536
|
const mesh = child;
|
|
@@ -1478,7 +1565,7 @@ function loadModelByUrl(url_1) {
|
|
|
1478
1565
|
return object;
|
|
1479
1566
|
});
|
|
1480
1567
|
}
|
|
1481
|
-
/**
|
|
1568
|
+
/** Runtime downscale textures in mesh to maxSize (canvas drawImage) to save GPU memory */
|
|
1482
1569
|
function downscaleTexturesInObject(obj, maxSize) {
|
|
1483
1570
|
obj.traverse((ch) => {
|
|
1484
1571
|
if (!ch.isMesh)
|
|
@@ -1521,10 +1608,10 @@ function downscaleTexturesInObject(obj, maxSize) {
|
|
|
1521
1608
|
});
|
|
1522
1609
|
}
|
|
1523
1610
|
/**
|
|
1524
|
-
*
|
|
1525
|
-
* -
|
|
1526
|
-
* -
|
|
1527
|
-
* -
|
|
1611
|
+
* Try to merge geometries in object (Only merge: non-transparent, non-SkinnedMesh, attribute compatible BufferGeometry)
|
|
1612
|
+
* - Before merging, apply world matrix to each mesh's geometry (so merged geometry is in world space)
|
|
1613
|
+
* - Merging will group by material UUID (different materials cannot be merged)
|
|
1614
|
+
* - Merge function is compatible with common export names of BufferGeometryUtils
|
|
1528
1615
|
*/
|
|
1529
1616
|
function tryMergeGeometries(root, opts) {
|
|
1530
1617
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -1604,9 +1691,9 @@ function tryMergeGeometries(root, opts) {
|
|
|
1604
1691
|
});
|
|
1605
1692
|
}
|
|
1606
1693
|
/* ---------------------
|
|
1607
|
-
|
|
1694
|
+
Dispose Utils
|
|
1608
1695
|
--------------------- */
|
|
1609
|
-
/**
|
|
1696
|
+
/** Completely dispose object: geometry, material and its textures (Danger: shared resources will be disposed) */
|
|
1610
1697
|
function disposeObject(obj) {
|
|
1611
1698
|
if (!obj)
|
|
1612
1699
|
return;
|
|
@@ -1629,7 +1716,7 @@ function disposeObject(obj) {
|
|
|
1629
1716
|
}
|
|
1630
1717
|
});
|
|
1631
1718
|
}
|
|
1632
|
-
/**
|
|
1719
|
+
/** Dispose material and its textures */
|
|
1633
1720
|
function disposeMaterial(mat) {
|
|
1634
1721
|
if (!mat)
|
|
1635
1722
|
return;
|
|
@@ -1648,25 +1735,45 @@ function disposeMaterial(mat) {
|
|
|
1648
1735
|
}
|
|
1649
1736
|
catch (_a) { }
|
|
1650
1737
|
}
|
|
1738
|
+
// Helper to convert to simple material (stub)
|
|
1739
|
+
function toSimpleMaterial(mat) {
|
|
1740
|
+
// Basic implementation, preserve color/map
|
|
1741
|
+
const m = new THREE.MeshBasicMaterial();
|
|
1742
|
+
if (mat.color)
|
|
1743
|
+
m.color.copy(mat.color);
|
|
1744
|
+
if (mat.map)
|
|
1745
|
+
m.map = mat.map;
|
|
1746
|
+
return m;
|
|
1747
|
+
}
|
|
1651
1748
|
|
|
1652
|
-
/**
|
|
1749
|
+
/**
|
|
1750
|
+
* @file skyboxLoader.ts
|
|
1751
|
+
* @description
|
|
1752
|
+
* Utility for loading skyboxes (CubeTexture or Equirectangular/HDR).
|
|
1753
|
+
*
|
|
1754
|
+
* @best-practice
|
|
1755
|
+
* - Use `loadSkybox` for a unified interface.
|
|
1756
|
+
* - Supports internal caching to avoid reloading the same skybox.
|
|
1757
|
+
* - Can set background and environment map independently.
|
|
1758
|
+
*/
|
|
1759
|
+
/** Default Values */
|
|
1653
1760
|
const DEFAULT_OPTIONS = {
|
|
1654
1761
|
setAsBackground: true,
|
|
1655
1762
|
setAsEnvironment: true,
|
|
1656
1763
|
useSRGBEncoding: true,
|
|
1657
1764
|
cache: true
|
|
1658
1765
|
};
|
|
1659
|
-
/**
|
|
1766
|
+
/** Internal Cache: key -> { handle, refCount } */
|
|
1660
1767
|
const cubeCache = new Map();
|
|
1661
1768
|
const equirectCache = new Map();
|
|
1662
1769
|
/* -------------------------------------------
|
|
1663
|
-
|
|
1770
|
+
Public Function: Load skybox (Automatically choose cube or equirect)
|
|
1664
1771
|
------------------------------------------- */
|
|
1665
1772
|
/**
|
|
1666
|
-
*
|
|
1667
|
-
* @param renderer THREE.WebGLRenderer -
|
|
1773
|
+
* Load Cube Texture (6 images)
|
|
1774
|
+
* @param renderer THREE.WebGLRenderer - Used for PMREM generating environment map
|
|
1668
1775
|
* @param scene THREE.Scene
|
|
1669
|
-
* @param paths string[] 6
|
|
1776
|
+
* @param paths string[] 6 image paths, order: [px, nx, py, ny, pz, nz]
|
|
1670
1777
|
* @param opts SkyboxOptions
|
|
1671
1778
|
*/
|
|
1672
1779
|
function loadCubeSkybox(renderer_1, scene_1, paths_1) {
|
|
@@ -1676,7 +1783,7 @@ function loadCubeSkybox(renderer_1, scene_1, paths_1) {
|
|
|
1676
1783
|
if (!Array.isArray(paths) || paths.length !== 6)
|
|
1677
1784
|
throw new Error('cube skybox requires 6 image paths');
|
|
1678
1785
|
const key = paths.join('|');
|
|
1679
|
-
//
|
|
1786
|
+
// Cache handling
|
|
1680
1787
|
if (options.cache && cubeCache.has(key)) {
|
|
1681
1788
|
const rec = cubeCache.get(key);
|
|
1682
1789
|
rec.refCount += 1;
|
|
@@ -1687,12 +1794,12 @@ function loadCubeSkybox(renderer_1, scene_1, paths_1) {
|
|
|
1687
1794
|
scene.environment = rec.handle.envRenderTarget.texture;
|
|
1688
1795
|
return rec.handle;
|
|
1689
1796
|
}
|
|
1690
|
-
//
|
|
1797
|
+
// Load cube texture
|
|
1691
1798
|
const loader = new THREE.CubeTextureLoader();
|
|
1692
1799
|
const texture = yield new Promise((resolve, reject) => {
|
|
1693
1800
|
loader.load(paths, (tex) => resolve(tex), undefined, (err) => reject(err));
|
|
1694
1801
|
});
|
|
1695
|
-
//
|
|
1802
|
+
// Set encoding and mapping
|
|
1696
1803
|
if (options.useSRGBEncoding)
|
|
1697
1804
|
texture.encoding = THREE.sRGBEncoding;
|
|
1698
1805
|
texture.mapping = THREE.CubeReflectionMapping;
|
|
@@ -1765,7 +1872,7 @@ function loadCubeSkybox(renderer_1, scene_1, paths_1) {
|
|
|
1765
1872
|
});
|
|
1766
1873
|
}
|
|
1767
1874
|
/**
|
|
1768
|
-
*
|
|
1875
|
+
* Load Equirectangular/Single Image (Supports HDR via RGBELoader)
|
|
1769
1876
|
* @param renderer THREE.WebGLRenderer
|
|
1770
1877
|
* @param scene THREE.Scene
|
|
1771
1878
|
* @param url string - *.hdr, *.exr, *.jpg, *.png
|
|
@@ -1785,7 +1892,7 @@ function loadEquirectSkybox(renderer_1, scene_1, url_1) {
|
|
|
1785
1892
|
scene.environment = rec.handle.envRenderTarget.texture;
|
|
1786
1893
|
return rec.handle;
|
|
1787
1894
|
}
|
|
1788
|
-
//
|
|
1895
|
+
// Dynamically import RGBELoader (for .hdr/.exr), if loading normal jpg/png directly use TextureLoader
|
|
1789
1896
|
const isHDR = /\.hdr$|\.exr$/i.test(url);
|
|
1790
1897
|
let hdrTexture;
|
|
1791
1898
|
if (isHDR) {
|
|
@@ -1862,9 +1969,9 @@ function loadSkybox(renderer_1, scene_1, params_1) {
|
|
|
1862
1969
|
});
|
|
1863
1970
|
}
|
|
1864
1971
|
/* -------------------------
|
|
1865
|
-
|
|
1972
|
+
Cache / Reference Counting Helper Methods
|
|
1866
1973
|
------------------------- */
|
|
1867
|
-
/**
|
|
1974
|
+
/** Release a cached skybox (decrements refCount, only truly disposes when refCount=0) */
|
|
1868
1975
|
function releaseSkybox(handle) {
|
|
1869
1976
|
// check cube cache
|
|
1870
1977
|
if (cubeCache.has(handle.key)) {
|
|
@@ -1889,85 +1996,94 @@ function releaseSkybox(handle) {
|
|
|
1889
1996
|
// handle.dispose()
|
|
1890
1997
|
}
|
|
1891
1998
|
|
|
1892
|
-
// utils/BlueSkyManager.ts - 优化版
|
|
1893
1999
|
/**
|
|
1894
|
-
*
|
|
2000
|
+
* @file blueSkyManager.ts
|
|
2001
|
+
* @description
|
|
2002
|
+
* Global singleton manager for loading and managing HDR/EXR blue sky environment maps.
|
|
2003
|
+
*
|
|
2004
|
+
* @best-practice
|
|
2005
|
+
* - Call `init` once before use.
|
|
2006
|
+
* - Use `loadAsync` to load skyboxes with progress tracking.
|
|
2007
|
+
* - Automatically handles PMREM generation for realistic lighting.
|
|
2008
|
+
*/
|
|
2009
|
+
/**
|
|
2010
|
+
* BlueSkyManager - Optimized
|
|
1895
2011
|
* ---------------------------------------------------------
|
|
1896
|
-
*
|
|
2012
|
+
* A global singleton manager for loading and managing HDR/EXR based blue sky environment maps.
|
|
1897
2013
|
*
|
|
1898
|
-
*
|
|
1899
|
-
* -
|
|
1900
|
-
* -
|
|
1901
|
-
* -
|
|
1902
|
-
* -
|
|
1903
|
-
* -
|
|
2014
|
+
* Features:
|
|
2015
|
+
* - Adds load progress callback
|
|
2016
|
+
* - Supports load cancellation
|
|
2017
|
+
* - Improved error handling
|
|
2018
|
+
* - Returns Promise for async operation
|
|
2019
|
+
* - Adds loading state management
|
|
1904
2020
|
*/
|
|
1905
2021
|
class BlueSkyManager {
|
|
1906
2022
|
constructor() {
|
|
1907
|
-
/**
|
|
2023
|
+
/** RenderTarget for current environment map, used for subsequent disposal */
|
|
1908
2024
|
this.skyRT = null;
|
|
1909
|
-
/**
|
|
2025
|
+
/** Whether already initialized */
|
|
1910
2026
|
this.isInitialized = false;
|
|
1911
|
-
/**
|
|
2027
|
+
/** Current loader, used for cancelling load */
|
|
1912
2028
|
this.currentLoader = null;
|
|
1913
|
-
/**
|
|
2029
|
+
/** Loading state */
|
|
1914
2030
|
this.loadingState = 'idle';
|
|
1915
2031
|
}
|
|
1916
2032
|
/**
|
|
1917
|
-
*
|
|
2033
|
+
* Initialize
|
|
1918
2034
|
* ---------------------------------------------------------
|
|
1919
|
-
*
|
|
1920
|
-
* @param renderer WebGLRenderer
|
|
1921
|
-
* @param scene Three.js
|
|
1922
|
-
* @param exposure
|
|
2035
|
+
* Must be called once before using BlueSkyManager.
|
|
2036
|
+
* @param renderer WebGLRenderer instance
|
|
2037
|
+
* @param scene Three.js Scene
|
|
2038
|
+
* @param exposure Exposure (default 1.0)
|
|
1923
2039
|
*/
|
|
1924
2040
|
init(renderer, scene, exposure = 1.0) {
|
|
1925
2041
|
if (this.isInitialized) {
|
|
1926
|
-
console.warn('BlueSkyManager:
|
|
2042
|
+
console.warn('BlueSkyManager: Already initialized, skipping duplicate initialization');
|
|
1927
2043
|
return;
|
|
1928
2044
|
}
|
|
1929
2045
|
this.renderer = renderer;
|
|
1930
2046
|
this.scene = scene;
|
|
1931
|
-
//
|
|
2047
|
+
// Use ACESFilmicToneMapping, effect is closer to reality
|
|
1932
2048
|
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
1933
2049
|
this.renderer.toneMappingExposure = exposure;
|
|
1934
|
-
//
|
|
2050
|
+
// Initialize PMREM generator (only one needed globally)
|
|
1935
2051
|
this.pmremGen = new THREE.PMREMGenerator(renderer);
|
|
1936
2052
|
this.pmremGen.compileEquirectangularShader();
|
|
1937
2053
|
this.isInitialized = true;
|
|
1938
2054
|
}
|
|
1939
2055
|
/**
|
|
1940
|
-
*
|
|
2056
|
+
* Load blue sky HDR/EXR map and apply to scene (Promise version)
|
|
1941
2057
|
* ---------------------------------------------------------
|
|
1942
|
-
* @param exrPath HDR/EXR
|
|
1943
|
-
* @param options
|
|
2058
|
+
* @param exrPath HDR/EXR file path
|
|
2059
|
+
* @param options Load options
|
|
1944
2060
|
* @returns Promise<void>
|
|
1945
2061
|
*/
|
|
1946
2062
|
loadAsync(exrPath, options = {}) {
|
|
1947
2063
|
if (!this.isInitialized) {
|
|
1948
2064
|
return Promise.reject(new Error('BlueSkyManager not initialized!'));
|
|
1949
2065
|
}
|
|
1950
|
-
//
|
|
2066
|
+
// Cancel previous load
|
|
1951
2067
|
this.cancelLoad();
|
|
1952
2068
|
const { background = true, onProgress, onComplete, onError } = options;
|
|
1953
2069
|
this.loadingState = 'loading';
|
|
1954
2070
|
this.currentLoader = new EXRLoader();
|
|
1955
2071
|
return new Promise((resolve, reject) => {
|
|
1956
2072
|
this.currentLoader.load(exrPath,
|
|
1957
|
-
//
|
|
2073
|
+
// Success callback
|
|
1958
2074
|
(texture) => {
|
|
1959
2075
|
try {
|
|
1960
|
-
//
|
|
2076
|
+
// Set texture mapping to EquirectangularReflectionMapping
|
|
1961
2077
|
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
1962
|
-
//
|
|
2078
|
+
// Clear old environment map
|
|
1963
2079
|
this.dispose();
|
|
1964
|
-
//
|
|
2080
|
+
// Generate efficient environment map using PMREM
|
|
1965
2081
|
this.skyRT = this.pmremGen.fromEquirectangular(texture);
|
|
1966
|
-
//
|
|
2082
|
+
// Apply to scene: Environment Lighting & Background
|
|
1967
2083
|
this.scene.environment = this.skyRT.texture;
|
|
1968
2084
|
if (background)
|
|
1969
2085
|
this.scene.background = this.skyRT.texture;
|
|
1970
|
-
//
|
|
2086
|
+
// Dispose original HDR/EXR texture immediately to save memory
|
|
1971
2087
|
texture.dispose();
|
|
1972
2088
|
this.loadingState = 'loaded';
|
|
1973
2089
|
this.currentLoader = null;
|
|
@@ -1985,14 +2101,14 @@ class BlueSkyManager {
|
|
|
1985
2101
|
reject(error);
|
|
1986
2102
|
}
|
|
1987
2103
|
},
|
|
1988
|
-
//
|
|
2104
|
+
// Progress callback
|
|
1989
2105
|
(xhr) => {
|
|
1990
2106
|
if (onProgress && xhr.lengthComputable) {
|
|
1991
2107
|
const progress = xhr.loaded / xhr.total;
|
|
1992
2108
|
onProgress(progress);
|
|
1993
2109
|
}
|
|
1994
2110
|
},
|
|
1995
|
-
//
|
|
2111
|
+
// Error callback
|
|
1996
2112
|
(err) => {
|
|
1997
2113
|
this.loadingState = 'error';
|
|
1998
2114
|
this.currentLoader = null;
|
|
@@ -2004,10 +2120,10 @@ class BlueSkyManager {
|
|
|
2004
2120
|
});
|
|
2005
2121
|
}
|
|
2006
2122
|
/**
|
|
2007
|
-
*
|
|
2123
|
+
* Load blue sky HDR/EXR map and apply to scene (Sync API, for backward compatibility)
|
|
2008
2124
|
* ---------------------------------------------------------
|
|
2009
|
-
* @param exrPath HDR/EXR
|
|
2010
|
-
* @param background
|
|
2125
|
+
* @param exrPath HDR/EXR file path
|
|
2126
|
+
* @param background Whether to apply as scene background (default true)
|
|
2011
2127
|
*/
|
|
2012
2128
|
load(exrPath, background = true) {
|
|
2013
2129
|
this.loadAsync(exrPath, { background }).catch((error) => {
|
|
@@ -2015,32 +2131,32 @@ class BlueSkyManager {
|
|
|
2015
2131
|
});
|
|
2016
2132
|
}
|
|
2017
2133
|
/**
|
|
2018
|
-
*
|
|
2134
|
+
* Cancel current load
|
|
2019
2135
|
*/
|
|
2020
2136
|
cancelLoad() {
|
|
2021
2137
|
if (this.currentLoader) {
|
|
2022
|
-
// EXRLoader
|
|
2138
|
+
// EXRLoader itself does not have abort method, but we can clear the reference
|
|
2023
2139
|
this.currentLoader = null;
|
|
2024
2140
|
this.loadingState = 'idle';
|
|
2025
2141
|
}
|
|
2026
2142
|
}
|
|
2027
2143
|
/**
|
|
2028
|
-
*
|
|
2144
|
+
* Get loading state
|
|
2029
2145
|
*/
|
|
2030
2146
|
getLoadingState() {
|
|
2031
2147
|
return this.loadingState;
|
|
2032
2148
|
}
|
|
2033
2149
|
/**
|
|
2034
|
-
*
|
|
2150
|
+
* Is loading
|
|
2035
2151
|
*/
|
|
2036
2152
|
isLoading() {
|
|
2037
2153
|
return this.loadingState === 'loading';
|
|
2038
2154
|
}
|
|
2039
2155
|
/**
|
|
2040
|
-
*
|
|
2156
|
+
* Release current sky texture resources
|
|
2041
2157
|
* ---------------------------------------------------------
|
|
2042
|
-
*
|
|
2043
|
-
*
|
|
2158
|
+
* Only cleans up skyRT, does not destroy PMREM
|
|
2159
|
+
* Suitable for calling when switching HDR/EXR files
|
|
2044
2160
|
*/
|
|
2045
2161
|
dispose() {
|
|
2046
2162
|
if (this.skyRT) {
|
|
@@ -2054,10 +2170,10 @@ class BlueSkyManager {
|
|
|
2054
2170
|
this.scene.environment = null;
|
|
2055
2171
|
}
|
|
2056
2172
|
/**
|
|
2057
|
-
*
|
|
2173
|
+
* Completely destroy BlueSkyManager
|
|
2058
2174
|
* ---------------------------------------------------------
|
|
2059
|
-
*
|
|
2060
|
-
*
|
|
2175
|
+
* Includes destruction of PMREMGenerator
|
|
2176
|
+
* Usually called when the scene is completely destroyed or the application exits
|
|
2061
2177
|
*/
|
|
2062
2178
|
destroy() {
|
|
2063
2179
|
var _a;
|
|
@@ -2069,22 +2185,32 @@ class BlueSkyManager {
|
|
|
2069
2185
|
}
|
|
2070
2186
|
}
|
|
2071
2187
|
/**
|
|
2072
|
-
*
|
|
2188
|
+
* Global Singleton
|
|
2073
2189
|
* ---------------------------------------------------------
|
|
2074
|
-
*
|
|
2075
|
-
*
|
|
2190
|
+
* Directly export a globally unique BlueSkyManager instance,
|
|
2191
|
+
* Ensuring only one PMREMGenerator is used throughout the application for best performance.
|
|
2076
2192
|
*/
|
|
2077
2193
|
const BlueSky = new BlueSkyManager();
|
|
2078
2194
|
|
|
2079
2195
|
/**
|
|
2080
|
-
*
|
|
2196
|
+
* @file modelsLabel.ts
|
|
2197
|
+
* @description
|
|
2198
|
+
* Creates interactive 2D labels (DOM elements) attached to 3D objects with connecting lines.
|
|
2199
|
+
*
|
|
2200
|
+
* @best-practice
|
|
2201
|
+
* - Use `createModelsLabel` to annotate parts of a model.
|
|
2202
|
+
* - Supports fading endpoints, pulsing dots, and custom styling.
|
|
2203
|
+
* - Performance optimized with caching and RAF throttling.
|
|
2204
|
+
*/
|
|
2205
|
+
/**
|
|
2206
|
+
* Create Model Labels (with connecting lines and pulsing dots) - Optimized
|
|
2081
2207
|
*
|
|
2082
|
-
*
|
|
2083
|
-
* -
|
|
2084
|
-
* -
|
|
2085
|
-
* -
|
|
2086
|
-
* -
|
|
2087
|
-
* - RAF
|
|
2208
|
+
* Features:
|
|
2209
|
+
* - Supports pause/resume
|
|
2210
|
+
* - Configurable update interval
|
|
2211
|
+
* - Fade in/out effects
|
|
2212
|
+
* - Cached bounding box calculation
|
|
2213
|
+
* - RAF management optimization
|
|
2088
2214
|
*/
|
|
2089
2215
|
function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, options) {
|
|
2090
2216
|
var _a, _b, _c, _d, _e, _f;
|
|
@@ -2099,8 +2225,8 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2099
2225
|
dotSpacing: (_c = options === null || options === void 0 ? void 0 : options.dotSpacing) !== null && _c !== void 0 ? _c : 2,
|
|
2100
2226
|
lineColor: (options === null || options === void 0 ? void 0 : options.lineColor) || 'rgba(200,200,200,0.7)',
|
|
2101
2227
|
lineWidth: (_d = options === null || options === void 0 ? void 0 : options.lineWidth) !== null && _d !== void 0 ? _d : 1,
|
|
2102
|
-
updateInterval: (_e = options === null || options === void 0 ? void 0 : options.updateInterval) !== null && _e !== void 0 ? _e : 0, //
|
|
2103
|
-
fadeInDuration: (_f = options === null || options === void 0 ? void 0 : options.fadeInDuration) !== null && _f !== void 0 ? _f : 300, //
|
|
2228
|
+
updateInterval: (_e = options === null || options === void 0 ? void 0 : options.updateInterval) !== null && _e !== void 0 ? _e : 0, // Default update every frame
|
|
2229
|
+
fadeInDuration: (_f = options === null || options === void 0 ? void 0 : options.fadeInDuration) !== null && _f !== void 0 ? _f : 300, // Fade-in duration
|
|
2104
2230
|
};
|
|
2105
2231
|
const container = document.createElement('div');
|
|
2106
2232
|
container.style.position = 'absolute';
|
|
@@ -2126,10 +2252,10 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2126
2252
|
let currentLabelsMap = Object.assign({}, modelLabelsMap);
|
|
2127
2253
|
let labels = [];
|
|
2128
2254
|
let isActive = true;
|
|
2129
|
-
let isPaused = false;
|
|
2130
|
-
let rafId = null;
|
|
2131
|
-
let lastUpdateTime = 0;
|
|
2132
|
-
//
|
|
2255
|
+
let isPaused = false;
|
|
2256
|
+
let rafId = null;
|
|
2257
|
+
let lastUpdateTime = 0;
|
|
2258
|
+
// Inject styles (with fade-in animation)
|
|
2133
2259
|
const styleId = 'three-model-label-styles';
|
|
2134
2260
|
if (!document.getElementById(styleId)) {
|
|
2135
2261
|
const style = document.createElement('style');
|
|
@@ -2168,14 +2294,14 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2168
2294
|
`;
|
|
2169
2295
|
document.head.appendChild(style);
|
|
2170
2296
|
}
|
|
2171
|
-
//
|
|
2297
|
+
// Get or update cached top position
|
|
2172
2298
|
const getObjectTopPosition = (labelData) => {
|
|
2173
2299
|
const obj = labelData.object;
|
|
2174
|
-
//
|
|
2300
|
+
// If cached and object hasn't transformed, return cached
|
|
2175
2301
|
if (labelData.cachedTopPos && !obj.matrixWorldNeedsUpdate) {
|
|
2176
2302
|
return labelData.cachedTopPos.clone();
|
|
2177
2303
|
}
|
|
2178
|
-
//
|
|
2304
|
+
// Recalculate
|
|
2179
2305
|
const box = new THREE.Box3().setFromObject(obj);
|
|
2180
2306
|
labelData.cachedBox = box;
|
|
2181
2307
|
if (!box.isEmpty()) {
|
|
@@ -2253,20 +2379,20 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2253
2379
|
wrapper,
|
|
2254
2380
|
dot,
|
|
2255
2381
|
line,
|
|
2256
|
-
cachedBox: null, //
|
|
2382
|
+
cachedBox: null, // Initialize cache
|
|
2257
2383
|
cachedTopPos: null
|
|
2258
2384
|
});
|
|
2259
2385
|
}
|
|
2260
2386
|
});
|
|
2261
2387
|
};
|
|
2262
2388
|
rebuildLabels();
|
|
2263
|
-
//
|
|
2389
|
+
// Optimized update function
|
|
2264
2390
|
const updateLabels = (timestamp) => {
|
|
2265
2391
|
if (!isActive || isPaused) {
|
|
2266
2392
|
rafId = null;
|
|
2267
2393
|
return;
|
|
2268
2394
|
}
|
|
2269
|
-
//
|
|
2395
|
+
// Throttle
|
|
2270
2396
|
if (cfg.updateInterval > 0 && timestamp - lastUpdateTime < cfg.updateInterval) {
|
|
2271
2397
|
rafId = requestAnimationFrame(updateLabels);
|
|
2272
2398
|
return;
|
|
@@ -2279,7 +2405,7 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2279
2405
|
svg.setAttribute('height', `${height}`);
|
|
2280
2406
|
labels.forEach((labelData) => {
|
|
2281
2407
|
const { el, wrapper, dot, line } = labelData;
|
|
2282
|
-
const topWorld = getObjectTopPosition(labelData); //
|
|
2408
|
+
const topWorld = getObjectTopPosition(labelData); // Use cache
|
|
2283
2409
|
const topNDC = topWorld.clone().project(camera);
|
|
2284
2410
|
const modelX = (topNDC.x * 0.5 + 0.5) * width + rect.left;
|
|
2285
2411
|
const modelY = (-(topNDC.y * 0.5) + 0.5) * height + rect.top;
|
|
@@ -2314,7 +2440,7 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2314
2440
|
currentLabelsMap = Object.assign({}, newMap);
|
|
2315
2441
|
rebuildLabels();
|
|
2316
2442
|
},
|
|
2317
|
-
//
|
|
2443
|
+
// Pause update
|
|
2318
2444
|
pause() {
|
|
2319
2445
|
isPaused = true;
|
|
2320
2446
|
if (rafId !== null) {
|
|
@@ -2322,7 +2448,7 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2322
2448
|
rafId = null;
|
|
2323
2449
|
}
|
|
2324
2450
|
},
|
|
2325
|
-
//
|
|
2451
|
+
// Resume update
|
|
2326
2452
|
resume() {
|
|
2327
2453
|
if (!isPaused)
|
|
2328
2454
|
return;
|
|
@@ -2343,10 +2469,34 @@ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, option
|
|
|
2343
2469
|
};
|
|
2344
2470
|
}
|
|
2345
2471
|
|
|
2472
|
+
/**
|
|
2473
|
+
* @file exploder.ts
|
|
2474
|
+
* @description
|
|
2475
|
+
* GroupExploder - Three.js based model explosion effect tool (Vue3 + TS Support)
|
|
2476
|
+
* ----------------------------------------------------------------------
|
|
2477
|
+
* This tool is used to perform "explode / restore" animations on a set of specified Meshes:
|
|
2478
|
+
* - Initialize only once (onMounted)
|
|
2479
|
+
* - Supports dynamic switching of models and automatically restores the explosion state of the previous model
|
|
2480
|
+
* - Supports multiple arrangement modes (ring / spiral / grid / radial)
|
|
2481
|
+
* - Supports automatic transparency for non-exploded objects (dimOthers)
|
|
2482
|
+
* - Supports automatic camera positioning to the best observation point
|
|
2483
|
+
* - All animations use native requestAnimationFrame
|
|
2484
|
+
*
|
|
2485
|
+
* @best-practice
|
|
2486
|
+
* - Initialize in `onMounted`.
|
|
2487
|
+
* - Use `setMeshes` to update the active set of meshes to explode.
|
|
2488
|
+
* - Call `explode()` to trigger the effect and `restore()` to reset.
|
|
2489
|
+
*/
|
|
2346
2490
|
function easeInOutQuad(t) {
|
|
2347
2491
|
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
2348
2492
|
}
|
|
2349
2493
|
class GroupExploder {
|
|
2494
|
+
/**
|
|
2495
|
+
* Constructor
|
|
2496
|
+
* @param scene Three.js Scene instance
|
|
2497
|
+
* @param camera Three.js Camera (usually PerspectiveCamera)
|
|
2498
|
+
* @param controls OrbitControls instance (must be bound to camera)
|
|
2499
|
+
*/
|
|
2350
2500
|
constructor(scene, camera, controls) {
|
|
2351
2501
|
// sets and snapshots
|
|
2352
2502
|
this.currentSet = null;
|
|
@@ -2378,10 +2528,12 @@ class GroupExploder {
|
|
|
2378
2528
|
this.log('init() called');
|
|
2379
2529
|
}
|
|
2380
2530
|
/**
|
|
2381
|
-
*
|
|
2531
|
+
* Set the current set of meshes for explosion.
|
|
2382
2532
|
* - Detects content-level changes even if same Set reference is used.
|
|
2383
2533
|
* - Preserves prevSet/stateMap to allow async restore when needed.
|
|
2384
2534
|
* - Ensures stateMap contains snapshots for *all meshes in the new set*.
|
|
2535
|
+
* @param newSet The new set of meshes
|
|
2536
|
+
* @param contextId Optional context ID to distinguish business scenarios
|
|
2385
2537
|
*/
|
|
2386
2538
|
setMeshes(newSet, options) {
|
|
2387
2539
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -2583,6 +2735,11 @@ class GroupExploder {
|
|
|
2583
2735
|
return;
|
|
2584
2736
|
});
|
|
2585
2737
|
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Restore all exploded meshes to their original transform:
|
|
2740
|
+
* - Supports smooth animation
|
|
2741
|
+
* - Automatically cancels transparency
|
|
2742
|
+
*/
|
|
2586
2743
|
restore(duration = 400) {
|
|
2587
2744
|
if (!this.currentSet || this.currentSet.size === 0) {
|
|
2588
2745
|
this.log('restore: no currentSet to restore');
|
|
@@ -2939,120 +3096,142 @@ class GroupExploder {
|
|
|
2939
3096
|
return targets;
|
|
2940
3097
|
}
|
|
2941
3098
|
animateCameraToFit(targetCenter, targetRadius, opts) {
|
|
2942
|
-
var _a, _b, _c;
|
|
3099
|
+
var _a, _b, _c, _d;
|
|
2943
3100
|
const duration = (_a = opts === null || opts === void 0 ? void 0 : opts.duration) !== null && _a !== void 0 ? _a : 600;
|
|
2944
3101
|
const padding = (_b = opts === null || opts === void 0 ? void 0 : opts.padding) !== null && _b !== void 0 ? _b : 1.5;
|
|
2945
3102
|
if (!(this.camera instanceof THREE.PerspectiveCamera)) {
|
|
2946
3103
|
if (this.controls && this.controls.target) {
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
3104
|
+
// Fallback for non-PerspectiveCamera
|
|
3105
|
+
const startTarget = this.controls.target.clone();
|
|
3106
|
+
const startPos = this.camera.position.clone();
|
|
3107
|
+
const endTarget = targetCenter.clone();
|
|
3108
|
+
const dir = startPos.clone().sub(startTarget).normalize();
|
|
3109
|
+
const dist = startPos.distanceTo(startTarget);
|
|
3110
|
+
const endPos = endTarget.clone().add(dir.multiplyScalar(dist));
|
|
3111
|
+
const startTime = performance.now();
|
|
3112
|
+
const tick = (now) => {
|
|
3113
|
+
var _a;
|
|
3114
|
+
const t = Math.min(1, (now - startTime) / duration);
|
|
3115
|
+
const k = easeInOutQuad(t);
|
|
3116
|
+
if (this.controls && this.controls.target) {
|
|
3117
|
+
this.controls.target.lerpVectors(startTarget, endTarget, k);
|
|
3118
|
+
}
|
|
3119
|
+
this.camera.position.lerpVectors(startPos, endPos, k);
|
|
3120
|
+
if ((_a = this.controls) === null || _a === void 0 ? void 0 : _a.update)
|
|
3121
|
+
this.controls.update();
|
|
3122
|
+
if (t < 1) {
|
|
3123
|
+
this.cameraAnimId = requestAnimationFrame(tick);
|
|
3124
|
+
}
|
|
3125
|
+
else {
|
|
3126
|
+
this.cameraAnimId = null;
|
|
3127
|
+
}
|
|
3128
|
+
};
|
|
3129
|
+
this.cameraAnimId = requestAnimationFrame(tick);
|
|
2950
3130
|
}
|
|
2951
3131
|
return Promise.resolve();
|
|
2952
3132
|
}
|
|
2953
|
-
|
|
2954
|
-
const fov = (
|
|
2955
|
-
const
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
3133
|
+
// PerspectiveCamera logic
|
|
3134
|
+
const fov = THREE.MathUtils.degToRad(this.camera.fov);
|
|
3135
|
+
const aspect = this.camera.aspect;
|
|
3136
|
+
// Calculate distance needed to fit the sphere
|
|
3137
|
+
// tan(fov/2) = radius / distance => distance = radius / tan(fov/2)
|
|
3138
|
+
// We also consider aspect ratio for horizontal fit
|
|
3139
|
+
const distV = targetRadius / Math.sin(fov / 2);
|
|
3140
|
+
const distH = targetRadius / Math.sin(Math.min(fov, fov * aspect) / 2); // approximate
|
|
3141
|
+
const dist = Math.max(distV, distH) * padding;
|
|
3142
|
+
const startPos = this.camera.position.clone();
|
|
3143
|
+
const startTarget = ((_c = this.controls) === null || _c === void 0 ? void 0 : _c.target) ? this.controls.target.clone() : new THREE.Vector3(); // assumption
|
|
3144
|
+
if (!((_d = this.controls) === null || _d === void 0 ? void 0 : _d.target)) {
|
|
3145
|
+
this.camera.getWorldDirection(startTarget);
|
|
3146
|
+
startTarget.add(startPos);
|
|
3147
|
+
}
|
|
3148
|
+
// Determine end position: keep current viewing direction relative to center
|
|
3149
|
+
const dir = startPos.clone().sub(startTarget).normalize();
|
|
3150
|
+
if (dir.lengthSq() < 0.001)
|
|
2960
3151
|
dir.set(0, 0, 1);
|
|
2961
|
-
else
|
|
2962
|
-
dir.normalize();
|
|
2963
|
-
const newCamPos = targetCenter.clone().add(dir.multiplyScalar(desiredDistance));
|
|
2964
|
-
const startPos = cam.position.clone();
|
|
2965
|
-
const startTarget = (this.controls && this.controls.target) ? (this.controls.target.clone()) : this.getCameraLookAtPoint();
|
|
2966
3152
|
const endTarget = targetCenter.clone();
|
|
2967
|
-
const
|
|
3153
|
+
const endPos = endTarget.clone().add(dir.multiplyScalar(dist));
|
|
2968
3154
|
return new Promise((resolve) => {
|
|
3155
|
+
const startTime = performance.now();
|
|
2969
3156
|
const tick = (now) => {
|
|
2970
|
-
|
|
2971
|
-
const
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
3157
|
+
var _a, _b;
|
|
3158
|
+
const t = Math.min(1, (now - startTime) / duration);
|
|
3159
|
+
const k = easeInOutQuad(t);
|
|
3160
|
+
this.camera.position.lerpVectors(startPos, endPos, k);
|
|
3161
|
+
if (this.controls && this.controls.target) {
|
|
3162
|
+
this.controls.target.lerpVectors(startTarget, endTarget, k);
|
|
3163
|
+
(_b = (_a = this.controls).update) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
3164
|
+
}
|
|
3165
|
+
else {
|
|
3166
|
+
this.camera.lookAt(endTarget); // simple lookAt if no controls
|
|
3167
|
+
}
|
|
3168
|
+
if (t < 1) {
|
|
2979
3169
|
this.cameraAnimId = requestAnimationFrame(tick);
|
|
3170
|
+
}
|
|
2980
3171
|
else {
|
|
2981
3172
|
this.cameraAnimId = null;
|
|
2982
|
-
this.log(`animateCameraToFit: done. center=${targetCenter.toArray().map((n) => n.toFixed(2))}, radius=${targetRadius.toFixed(2)}`);
|
|
2983
3173
|
resolve();
|
|
2984
3174
|
}
|
|
2985
3175
|
};
|
|
2986
3176
|
this.cameraAnimId = requestAnimationFrame(tick);
|
|
2987
3177
|
});
|
|
2988
3178
|
}
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
return this.camera.position.clone().add(dir.multiplyScalar(10));
|
|
2993
|
-
}
|
|
3179
|
+
/**
|
|
3180
|
+
* Cancel all running animations
|
|
3181
|
+
*/
|
|
2994
3182
|
cancelAnimations() {
|
|
2995
|
-
if (this.animId) {
|
|
3183
|
+
if (this.animId !== null) {
|
|
2996
3184
|
cancelAnimationFrame(this.animId);
|
|
2997
3185
|
this.animId = null;
|
|
2998
3186
|
}
|
|
2999
|
-
if (this.cameraAnimId) {
|
|
3187
|
+
if (this.cameraAnimId !== null) {
|
|
3000
3188
|
cancelAnimationFrame(this.cameraAnimId);
|
|
3001
3189
|
this.cameraAnimId = null;
|
|
3002
3190
|
}
|
|
3003
3191
|
}
|
|
3192
|
+
/**
|
|
3193
|
+
* Dispose: remove listener, cancel animation, clear references
|
|
3194
|
+
*/
|
|
3004
3195
|
dispose() {
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
for (const [mat, ctxs] of Array.from(this.materialContexts.entries())) {
|
|
3015
|
-
const snap = this.materialSnaps.get(mat);
|
|
3016
|
-
if (snap) {
|
|
3017
|
-
mat.transparent = snap.transparent;
|
|
3018
|
-
mat.opacity = snap.opacity;
|
|
3019
|
-
if (typeof snap.depthWrite !== 'undefined')
|
|
3020
|
-
mat.depthWrite = snap.depthWrite;
|
|
3021
|
-
mat.needsUpdate = true;
|
|
3022
|
-
}
|
|
3023
|
-
this.materialContexts.delete(mat);
|
|
3024
|
-
this.materialSnaps.delete(mat);
|
|
3025
|
-
}
|
|
3026
|
-
this.contextMaterials.clear();
|
|
3027
|
-
this.stateMap.clear();
|
|
3028
|
-
this.prevStateMap.clear();
|
|
3029
|
-
this.currentSet = null;
|
|
3030
|
-
this.prevSet = null;
|
|
3031
|
-
this.isInitialized = false;
|
|
3032
|
-
this.isExploded = false;
|
|
3033
|
-
this.log('dispose: cleaned up');
|
|
3034
|
-
});
|
|
3196
|
+
this.cancelAnimations();
|
|
3197
|
+
this.currentSet = null;
|
|
3198
|
+
this.prevSet = null;
|
|
3199
|
+
this.stateMap.clear();
|
|
3200
|
+
this.prevStateMap.clear();
|
|
3201
|
+
this.materialContexts.clear();
|
|
3202
|
+
this.materialSnaps.clear();
|
|
3203
|
+
this.contextMaterials.clear();
|
|
3204
|
+
this.log('dispose() called, resources cleaned up');
|
|
3035
3205
|
}
|
|
3036
3206
|
}
|
|
3037
3207
|
|
|
3038
3208
|
/**
|
|
3039
|
-
*
|
|
3209
|
+
* @file autoSetup.ts
|
|
3210
|
+
* @description
|
|
3211
|
+
* Automatically sets up the camera and basic lighting scene based on the model's bounding box.
|
|
3212
|
+
*
|
|
3213
|
+
* @best-practice
|
|
3214
|
+
* - Call `autoSetupCameraAndLight` after loading a model to get a quick "good looking" scene.
|
|
3215
|
+
* - Returns a handle to dispose lights or update intensity later.
|
|
3216
|
+
*/
|
|
3217
|
+
/**
|
|
3218
|
+
* Automatically setup camera and basic lighting - Optimized
|
|
3040
3219
|
*
|
|
3041
|
-
*
|
|
3042
|
-
* -
|
|
3043
|
-
* -
|
|
3044
|
-
* -
|
|
3220
|
+
* Features:
|
|
3221
|
+
* - Adds light intensity adjustment method
|
|
3222
|
+
* - Improved error handling
|
|
3223
|
+
* - Optimized dispose logic
|
|
3045
3224
|
*
|
|
3046
|
-
* - camera: THREE.PerspectiveCamera
|
|
3047
|
-
* - scene: THREE.Scene
|
|
3048
|
-
* - model: THREE.Object3D
|
|
3049
|
-
* - options:
|
|
3225
|
+
* - camera: THREE.PerspectiveCamera (will be moved and pointed at model center)
|
|
3226
|
+
* - scene: THREE.Scene (newly created light group will be added to the scene)
|
|
3227
|
+
* - model: THREE.Object3D loaded model (arbitrary transform/coordinates)
|
|
3228
|
+
* - options: Optional configuration (see AutoSetupOptions)
|
|
3050
3229
|
*
|
|
3051
|
-
*
|
|
3230
|
+
* Returns AutoSetupHandle, caller should call handle.dispose() when component unmounts/switches
|
|
3052
3231
|
*/
|
|
3053
3232
|
function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
3054
3233
|
var _a, _b, _c, _d, _e, _f, _g;
|
|
3055
|
-
//
|
|
3234
|
+
// Boundary check
|
|
3056
3235
|
if (!camera || !scene || !model) {
|
|
3057
3236
|
throw new Error('autoSetupCameraAndLight: camera, scene, model are required');
|
|
3058
3237
|
}
|
|
@@ -3066,9 +3245,9 @@ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
|
3066
3245
|
renderer: (_g = options.renderer) !== null && _g !== void 0 ? _g : null,
|
|
3067
3246
|
};
|
|
3068
3247
|
try {
|
|
3069
|
-
// --- 1)
|
|
3248
|
+
// --- 1) Calculate bounding data
|
|
3070
3249
|
const box = new THREE.Box3().setFromObject(model);
|
|
3071
|
-
//
|
|
3250
|
+
// Check bounding box validity
|
|
3072
3251
|
if (!isFinite(box.min.x)) {
|
|
3073
3252
|
throw new Error('autoSetupCameraAndLight: Invalid bounding box');
|
|
3074
3253
|
}
|
|
@@ -3076,7 +3255,7 @@ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
|
3076
3255
|
box.getBoundingSphere(sphere);
|
|
3077
3256
|
const center = sphere.center.clone();
|
|
3078
3257
|
const radius = Math.max(0.001, sphere.radius);
|
|
3079
|
-
// --- 2)
|
|
3258
|
+
// --- 2) Calculate camera position
|
|
3080
3259
|
const fov = (camera.fov * Math.PI) / 180;
|
|
3081
3260
|
const halfFov = fov / 2;
|
|
3082
3261
|
const sinHalfFov = Math.max(Math.sin(halfFov), 0.001);
|
|
@@ -3088,17 +3267,17 @@ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
|
3088
3267
|
camera.near = Math.max(0.001, radius / 1000);
|
|
3089
3268
|
camera.far = Math.max(1000, radius * 50);
|
|
3090
3269
|
camera.updateProjectionMatrix();
|
|
3091
|
-
// --- 3)
|
|
3270
|
+
// --- 3) Enable Shadows
|
|
3092
3271
|
if (opts.renderer && opts.enableShadows) {
|
|
3093
3272
|
opts.renderer.shadowMap.enabled = true;
|
|
3094
3273
|
opts.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
3095
3274
|
}
|
|
3096
|
-
// --- 4)
|
|
3275
|
+
// --- 4) Create Lights Group
|
|
3097
3276
|
const lightsGroup = new THREE.Group();
|
|
3098
3277
|
lightsGroup.name = 'autoSetupLightsGroup';
|
|
3099
3278
|
lightsGroup.position.copy(center);
|
|
3100
3279
|
scene.add(lightsGroup);
|
|
3101
|
-
// 4.1
|
|
3280
|
+
// 4.1 Basic Light
|
|
3102
3281
|
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.6);
|
|
3103
3282
|
hemi.name = 'auto_hemi';
|
|
3104
3283
|
hemi.position.set(0, radius * 2.0, 0);
|
|
@@ -3106,7 +3285,7 @@ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
|
3106
3285
|
const ambient = new THREE.AmbientLight(0xffffff, 0.25);
|
|
3107
3286
|
ambient.name = 'auto_ambient';
|
|
3108
3287
|
lightsGroup.add(ambient);
|
|
3109
|
-
// 4.2
|
|
3288
|
+
// 4.2 Directional Lights
|
|
3110
3289
|
const dirCount = Math.max(1, Math.floor(opts.directionalCount));
|
|
3111
3290
|
const directionalLights = [];
|
|
3112
3291
|
const dirs = [];
|
|
@@ -3141,7 +3320,7 @@ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
|
3141
3320
|
}
|
|
3142
3321
|
directionalLights.push(light);
|
|
3143
3322
|
}
|
|
3144
|
-
// 4.3
|
|
3323
|
+
// 4.3 Point Light Fill
|
|
3145
3324
|
const fill1 = new THREE.PointLight(0xffffff, 0.5, radius * 4);
|
|
3146
3325
|
fill1.position.copy(center).add(new THREE.Vector3(radius * 0.5, 0.2 * radius, 0));
|
|
3147
3326
|
fill1.name = 'auto_fill1';
|
|
@@ -3150,7 +3329,7 @@ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
|
3150
3329
|
fill2.position.copy(center).add(new THREE.Vector3(-radius * 0.5, -0.2 * radius, 0));
|
|
3151
3330
|
fill2.name = 'auto_fill2';
|
|
3152
3331
|
lightsGroup.add(fill2);
|
|
3153
|
-
// --- 5)
|
|
3332
|
+
// --- 5) Set Mesh Shadow Props
|
|
3154
3333
|
if (opts.setMeshShadowProps) {
|
|
3155
3334
|
model.traverse((ch) => {
|
|
3156
3335
|
if (ch.isMesh) {
|
|
@@ -3161,12 +3340,12 @@ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
|
3161
3340
|
}
|
|
3162
3341
|
});
|
|
3163
3342
|
}
|
|
3164
|
-
// --- 6)
|
|
3343
|
+
// --- 6) Return handle ---
|
|
3165
3344
|
const handle = {
|
|
3166
3345
|
lightsGroup,
|
|
3167
3346
|
center,
|
|
3168
3347
|
radius,
|
|
3169
|
-
//
|
|
3348
|
+
// Update light intensity
|
|
3170
3349
|
updateLightIntensity(factor) {
|
|
3171
3350
|
lightsGroup.traverse((node) => {
|
|
3172
3351
|
if (node.isLight) {
|
|
@@ -3178,10 +3357,10 @@ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
|
|
|
3178
3357
|
},
|
|
3179
3358
|
dispose: () => {
|
|
3180
3359
|
try {
|
|
3181
|
-
//
|
|
3360
|
+
// Remove lights group
|
|
3182
3361
|
if (lightsGroup.parent)
|
|
3183
3362
|
lightsGroup.parent.remove(lightsGroup);
|
|
3184
|
-
//
|
|
3363
|
+
// Dispose shadow resources
|
|
3185
3364
|
lightsGroup.traverse((node) => {
|
|
3186
3365
|
if (node.isLight) {
|
|
3187
3366
|
const l = node;
|