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