@chocozhang/three-model-render 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +134 -97
- package/dist/camera/index.d.ts +59 -36
- package/dist/camera/index.js +83 -67
- package/dist/camera/index.js.map +1 -1
- package/dist/camera/index.mjs +83 -67
- package/dist/camera/index.mjs.map +1 -1
- package/dist/core/index.d.ts +81 -28
- package/dist/core/index.js +194 -104
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +194 -105
- package/dist/core/index.mjs.map +1 -1
- package/dist/effect/index.d.ts +47 -134
- package/dist/effect/index.js +287 -288
- package/dist/effect/index.js.map +1 -1
- package/dist/effect/index.mjs +287 -288
- package/dist/effect/index.mjs.map +1 -1
- package/dist/index.d.ts +432 -349
- package/dist/index.js +1399 -1228
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1395 -1229
- package/dist/index.mjs.map +1 -1
- package/dist/interaction/index.d.ts +85 -52
- package/dist/interaction/index.js +168 -142
- package/dist/interaction/index.js.map +1 -1
- package/dist/interaction/index.mjs +168 -142
- package/dist/interaction/index.mjs.map +1 -1
- package/dist/loader/index.d.ts +106 -58
- package/dist/loader/index.js +492 -454
- package/dist/loader/index.js.map +1 -1
- package/dist/loader/index.mjs +491 -455
- package/dist/loader/index.mjs.map +1 -1
- package/dist/setup/index.d.ts +26 -24
- package/dist/setup/index.js +125 -163
- package/dist/setup/index.js.map +1 -1
- package/dist/setup/index.mjs +124 -164
- package/dist/setup/index.mjs.map +1 -1
- package/dist/ui/index.d.ts +18 -7
- package/dist/ui/index.js +45 -37
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/index.mjs +45 -37
- package/dist/ui/index.mjs.map +1 -1
- package/package.json +50 -22
|
@@ -2,28 +2,38 @@ import * as THREE from 'three';
|
|
|
2
2
|
import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* @file clickHandler.ts
|
|
6
|
+
* @description
|
|
7
|
+
* Tool for handling model clicks and highlighting (OutlinePass version).
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
|
|
9
|
+
* @best-practice
|
|
10
|
+
* - Use `createModelClickHandler` to setup interaction.
|
|
11
|
+
* - Handles debouncing and click threshold automatically.
|
|
12
|
+
* - Cleanup using the returned dispose function.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Create Model Click Highlight Tool (OutlinePass Version) - Optimized
|
|
16
|
+
*
|
|
17
|
+
* Features:
|
|
18
|
+
* - Uses AbortController to unify event lifecycle management
|
|
19
|
+
* - Supports debounce to avoid frequent triggering
|
|
20
|
+
* - Customizable Raycaster parameters
|
|
21
|
+
* - Dynamically adjusts outline thickness based on camera distance
|
|
12
22
|
*
|
|
13
|
-
* @param camera
|
|
14
|
-
* @param scene
|
|
15
|
-
* @param renderer
|
|
16
|
-
* @param outlinePass
|
|
17
|
-
* @param onClick
|
|
18
|
-
* @param options
|
|
19
|
-
* @returns
|
|
23
|
+
* @param camera Camera
|
|
24
|
+
* @param scene Scene
|
|
25
|
+
* @param renderer Renderer
|
|
26
|
+
* @param outlinePass Initialized OutlinePass
|
|
27
|
+
* @param onClick Click callback
|
|
28
|
+
* @param options Optional configuration
|
|
29
|
+
* @returns Dispose function, used to clean up events and resources
|
|
20
30
|
*/
|
|
21
31
|
function createModelClickHandler(camera, scene, renderer, outlinePass, onClick, options = {}) {
|
|
22
|
-
//
|
|
32
|
+
// Configuration
|
|
23
33
|
const { clickThreshold = 3, debounceDelay = 0, raycasterParams = {}, enableDynamicThickness = true, minThickness = 1, maxThickness = 10 } = options;
|
|
24
34
|
const raycaster = new THREE.Raycaster();
|
|
25
35
|
const mouse = new THREE.Vector2();
|
|
26
|
-
//
|
|
36
|
+
// Apply Raycaster custom parameters
|
|
27
37
|
if (raycasterParams.near !== undefined)
|
|
28
38
|
raycaster.near = raycasterParams.near;
|
|
29
39
|
if (raycasterParams.far !== undefined)
|
|
@@ -40,25 +50,25 @@ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick,
|
|
|
40
50
|
let startY = 0;
|
|
41
51
|
let selectedObject = null;
|
|
42
52
|
let debounceTimer = null;
|
|
43
|
-
//
|
|
53
|
+
// Use AbortController to manage events uniformly
|
|
44
54
|
const abortController = new AbortController();
|
|
45
55
|
const signal = abortController.signal;
|
|
46
|
-
/**
|
|
56
|
+
/** Restore object highlight (Clear OutlinePass.selectedObjects) */
|
|
47
57
|
function restoreObject() {
|
|
48
58
|
outlinePass.selectedObjects = [];
|
|
49
59
|
}
|
|
50
|
-
/**
|
|
60
|
+
/** Record mouse down position */
|
|
51
61
|
function handleMouseDown(event) {
|
|
52
62
|
startX = event.clientX;
|
|
53
63
|
startY = event.clientY;
|
|
54
64
|
}
|
|
55
|
-
/**
|
|
65
|
+
/** Mouse up determines click or drag (with debounce) */
|
|
56
66
|
function handleMouseUp(event) {
|
|
57
67
|
const dx = Math.abs(event.clientX - startX);
|
|
58
68
|
const dy = Math.abs(event.clientY - startY);
|
|
59
69
|
if (dx > clickThreshold || dy > clickThreshold)
|
|
60
|
-
return; //
|
|
61
|
-
//
|
|
70
|
+
return; // Drag does not trigger click
|
|
71
|
+
// Debounce processing
|
|
62
72
|
if (debounceDelay > 0) {
|
|
63
73
|
if (debounceTimer !== null) {
|
|
64
74
|
clearTimeout(debounceTimer);
|
|
@@ -72,7 +82,7 @@ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick,
|
|
|
72
82
|
processClick(event);
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
|
-
/**
|
|
85
|
+
/** Actual click processing logic */
|
|
76
86
|
function processClick(event) {
|
|
77
87
|
const rect = renderer.domElement.getBoundingClientRect();
|
|
78
88
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
@@ -81,59 +91,67 @@ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick,
|
|
|
81
91
|
const intersects = raycaster.intersectObjects(scene.children, true);
|
|
82
92
|
if (intersects.length > 0) {
|
|
83
93
|
let object = intersects[0].object;
|
|
84
|
-
//
|
|
94
|
+
// Click different model, clear previous highlight first
|
|
85
95
|
if (selectedObject && selectedObject !== object)
|
|
86
96
|
restoreObject();
|
|
87
97
|
selectedObject = object;
|
|
88
|
-
// highlightObject(selectedObject); //
|
|
98
|
+
// highlightObject(selectedObject); // Optional: whether to auto highlight
|
|
89
99
|
onClick(selectedObject, {
|
|
90
|
-
name: selectedObject.name || '
|
|
100
|
+
name: selectedObject.name || 'Unnamed Model',
|
|
91
101
|
position: selectedObject.getWorldPosition(new THREE.Vector3()),
|
|
92
102
|
uuid: selectedObject.uuid
|
|
93
103
|
});
|
|
94
104
|
}
|
|
95
105
|
else {
|
|
96
|
-
//
|
|
106
|
+
// Click blank -> Clear highlight
|
|
97
107
|
if (selectedObject)
|
|
98
108
|
restoreObject();
|
|
99
109
|
selectedObject = null;
|
|
100
110
|
onClick(null);
|
|
101
111
|
}
|
|
102
112
|
}
|
|
103
|
-
//
|
|
113
|
+
// Register events using signal from AbortController
|
|
104
114
|
renderer.domElement.addEventListener('mousedown', handleMouseDown, { signal });
|
|
105
115
|
renderer.domElement.addEventListener('mouseup', handleMouseUp, { signal });
|
|
106
|
-
/**
|
|
116
|
+
/** Dispose function: Unbind events and clear highlight */
|
|
107
117
|
return () => {
|
|
108
|
-
//
|
|
118
|
+
// Clear debounce timer
|
|
109
119
|
if (debounceTimer !== null) {
|
|
110
120
|
clearTimeout(debounceTimer);
|
|
111
121
|
debounceTimer = null;
|
|
112
122
|
}
|
|
113
|
-
//
|
|
123
|
+
// Unbind all events at once
|
|
114
124
|
abortController.abort();
|
|
115
|
-
//
|
|
125
|
+
// Clear highlight
|
|
116
126
|
restoreObject();
|
|
117
|
-
//
|
|
127
|
+
// Clear reference
|
|
118
128
|
selectedObject = null;
|
|
119
129
|
};
|
|
120
130
|
}
|
|
121
131
|
|
|
122
|
-
// src/utils/ArrowGuide.ts
|
|
123
132
|
/**
|
|
124
|
-
*
|
|
125
|
-
*
|
|
133
|
+
* @file arrowGuide.ts
|
|
134
|
+
* @description
|
|
135
|
+
* Arrow guide effect tool, supports highlighting models and fading other objects.
|
|
136
|
+
*
|
|
137
|
+
* @best-practice
|
|
138
|
+
* - Use `highlight` to focus on specific models.
|
|
139
|
+
* - Automatically manages materials and memory using WeakMap.
|
|
140
|
+
* - Call `dispose` when component unmounts.
|
|
141
|
+
*/
|
|
142
|
+
/**
|
|
143
|
+
* ArrowGuide - Optimized Version
|
|
144
|
+
* Arrow guide effect tool, supports highlighting models and fading other objects.
|
|
126
145
|
*
|
|
127
|
-
*
|
|
128
|
-
* -
|
|
129
|
-
* -
|
|
130
|
-
* -
|
|
131
|
-
* -
|
|
132
|
-
* -
|
|
146
|
+
* Features:
|
|
147
|
+
* - Uses WeakMap for automatic material recycling, preventing memory leaks
|
|
148
|
+
* - Uses AbortController to manage event lifecycle
|
|
149
|
+
* - Adds material reuse mechanism to reuse materials
|
|
150
|
+
* - Improved dispose logic ensuring complete resource release
|
|
151
|
+
* - Adds error handling and boundary checks
|
|
133
152
|
*/
|
|
134
153
|
class ArrowGuide {
|
|
135
154
|
constructor(renderer, camera, scene, options) {
|
|
136
|
-
var _a, _b, _c;
|
|
137
155
|
this.renderer = renderer;
|
|
138
156
|
this.camera = camera;
|
|
139
157
|
this.scene = scene;
|
|
@@ -144,45 +162,45 @@ class ArrowGuide {
|
|
|
144
162
|
this.clickThreshold = 10;
|
|
145
163
|
this.raycaster = new THREE.Raycaster();
|
|
146
164
|
this.mouse = new THREE.Vector2();
|
|
147
|
-
//
|
|
165
|
+
// Use WeakMap for automatic material recycling (GC friendly)
|
|
148
166
|
this.originalMaterials = new WeakMap();
|
|
149
167
|
this.fadedMaterials = new WeakMap();
|
|
150
|
-
//
|
|
168
|
+
// AbortController for event management
|
|
151
169
|
this.abortController = null;
|
|
152
|
-
//
|
|
170
|
+
// Config: Non-highlight opacity and brightness
|
|
153
171
|
this.fadeOpacity = 0.5;
|
|
154
172
|
this.fadeBrightness = 0.1;
|
|
155
|
-
this.clickThreshold =
|
|
156
|
-
this.ignoreRaycastNames = new Set(
|
|
157
|
-
this.fadeOpacity =
|
|
158
|
-
this.fadeBrightness =
|
|
173
|
+
this.clickThreshold = options?.clickThreshold ?? 10;
|
|
174
|
+
this.ignoreRaycastNames = new Set(options?.ignoreRaycastNames || []);
|
|
175
|
+
this.fadeOpacity = options?.fadeOpacity ?? 0.5;
|
|
176
|
+
this.fadeBrightness = options?.fadeBrightness ?? 0.1;
|
|
159
177
|
this.abortController = new AbortController();
|
|
160
178
|
this.initEvents();
|
|
161
179
|
}
|
|
162
|
-
//
|
|
180
|
+
// Tool: Cache original material (first time only)
|
|
163
181
|
cacheOriginalMaterial(mesh) {
|
|
164
182
|
if (!this.originalMaterials.has(mesh)) {
|
|
165
183
|
this.originalMaterials.set(mesh, mesh.material);
|
|
166
184
|
}
|
|
167
185
|
}
|
|
168
|
-
//
|
|
186
|
+
// Tool: Clone a "translucent version" for a material, preserving all maps and parameters
|
|
169
187
|
makeFadedClone(mat) {
|
|
170
188
|
const clone = mat.clone();
|
|
171
189
|
const c = clone;
|
|
172
|
-
//
|
|
190
|
+
// Only modify transparency parameters, do not modify detail maps like map / normalMap / roughnessMap
|
|
173
191
|
c.transparent = true;
|
|
174
192
|
if (typeof c.opacity === 'number')
|
|
175
193
|
c.opacity = this.fadeOpacity;
|
|
176
194
|
if (c.color && c.color.isColor) {
|
|
177
|
-
c.color.multiplyScalar(this.fadeBrightness); //
|
|
195
|
+
c.color.multiplyScalar(this.fadeBrightness); // Darken color overall
|
|
178
196
|
}
|
|
179
|
-
//
|
|
197
|
+
// Common strategy for fluid display behind transparent objects: do not write depth, only test depth
|
|
180
198
|
clone.depthWrite = false;
|
|
181
199
|
clone.depthTest = true;
|
|
182
200
|
clone.needsUpdate = true;
|
|
183
201
|
return clone;
|
|
184
202
|
}
|
|
185
|
-
//
|
|
203
|
+
// Tool: Batch clone "translucent version" for mesh.material (could be array)
|
|
186
204
|
createFadedMaterialFrom(mesh) {
|
|
187
205
|
const orig = mesh.material;
|
|
188
206
|
if (Array.isArray(orig)) {
|
|
@@ -191,7 +209,7 @@ class ArrowGuide {
|
|
|
191
209
|
return this.makeFadedClone(orig);
|
|
192
210
|
}
|
|
193
211
|
/**
|
|
194
|
-
*
|
|
212
|
+
* Set Arrow Mesh
|
|
195
213
|
*/
|
|
196
214
|
setArrowMesh(mesh) {
|
|
197
215
|
this.lxMesh = mesh;
|
|
@@ -207,15 +225,15 @@ class ArrowGuide {
|
|
|
207
225
|
mesh.visible = false;
|
|
208
226
|
}
|
|
209
227
|
catch (error) {
|
|
210
|
-
console.error('ArrowGuide:
|
|
228
|
+
console.error('ArrowGuide: Failed to set arrow material', error);
|
|
211
229
|
}
|
|
212
230
|
}
|
|
213
231
|
/**
|
|
214
|
-
*
|
|
232
|
+
* Highlight specified models
|
|
215
233
|
*/
|
|
216
234
|
highlight(models) {
|
|
217
235
|
if (!models || models.length === 0) {
|
|
218
|
-
console.warn('ArrowGuide:
|
|
236
|
+
console.warn('ArrowGuide: Highlight model list is empty');
|
|
219
237
|
return;
|
|
220
238
|
}
|
|
221
239
|
this.modelBrightArr = models;
|
|
@@ -224,9 +242,9 @@ class ArrowGuide {
|
|
|
224
242
|
this.lxMesh.visible = true;
|
|
225
243
|
this.applyHighlight();
|
|
226
244
|
}
|
|
227
|
-
//
|
|
245
|
+
// Apply highlight effect: Non-highlighted models preserve details -> use "cloned translucent material"
|
|
228
246
|
applyHighlight() {
|
|
229
|
-
//
|
|
247
|
+
// Use Set to improve lookup performance
|
|
230
248
|
const keepMeshes = new Set();
|
|
231
249
|
this.modelBrightArr.forEach(obj => {
|
|
232
250
|
obj.traverse(child => {
|
|
@@ -238,21 +256,21 @@ class ArrowGuide {
|
|
|
238
256
|
this.scene.traverse(obj => {
|
|
239
257
|
if (obj.isMesh) {
|
|
240
258
|
const mesh = obj;
|
|
241
|
-
//
|
|
259
|
+
// Cache original material (for restoration)
|
|
242
260
|
this.cacheOriginalMaterial(mesh);
|
|
243
261
|
if (!keepMeshes.has(mesh)) {
|
|
244
|
-
//
|
|
262
|
+
// Non-highlighted: if no "translucent clone material" generated yet, create one
|
|
245
263
|
if (!this.fadedMaterials.has(mesh)) {
|
|
246
264
|
const faded = this.createFadedMaterialFrom(mesh);
|
|
247
265
|
this.fadedMaterials.set(mesh, faded);
|
|
248
266
|
}
|
|
249
|
-
//
|
|
267
|
+
// Replace with clone material (preserve all maps/normals details)
|
|
250
268
|
const fadedMat = this.fadedMaterials.get(mesh);
|
|
251
269
|
if (fadedMat)
|
|
252
270
|
mesh.material = fadedMat;
|
|
253
271
|
}
|
|
254
272
|
else {
|
|
255
|
-
//
|
|
273
|
+
// Highlighted object: ensure return to original material (avoid leftover from previous highlight)
|
|
256
274
|
const orig = this.originalMaterials.get(mesh);
|
|
257
275
|
if (orig && mesh.material !== orig) {
|
|
258
276
|
mesh.material = orig;
|
|
@@ -263,16 +281,16 @@ class ArrowGuide {
|
|
|
263
281
|
});
|
|
264
282
|
}
|
|
265
283
|
catch (error) {
|
|
266
|
-
console.error('ArrowGuide:
|
|
284
|
+
console.error('ArrowGuide: Failed to apply highlight', error);
|
|
267
285
|
}
|
|
268
286
|
}
|
|
269
|
-
//
|
|
287
|
+
// Restore to original material & dispose clone material
|
|
270
288
|
restore() {
|
|
271
289
|
this.flowActive = false;
|
|
272
290
|
if (this.lxMesh)
|
|
273
291
|
this.lxMesh.visible = false;
|
|
274
292
|
try {
|
|
275
|
-
//
|
|
293
|
+
// Collect all materials to dispose
|
|
276
294
|
const materialsToDispose = [];
|
|
277
295
|
this.scene.traverse(obj => {
|
|
278
296
|
if (obj.isMesh) {
|
|
@@ -282,7 +300,7 @@ class ArrowGuide {
|
|
|
282
300
|
mesh.material = orig;
|
|
283
301
|
mesh.material.needsUpdate = true;
|
|
284
302
|
}
|
|
285
|
-
//
|
|
303
|
+
// Collect faded materials to dispose
|
|
286
304
|
const faded = this.fadedMaterials.get(mesh);
|
|
287
305
|
if (faded) {
|
|
288
306
|
if (Array.isArray(faded)) {
|
|
@@ -294,24 +312,24 @@ class ArrowGuide {
|
|
|
294
312
|
}
|
|
295
313
|
}
|
|
296
314
|
});
|
|
297
|
-
//
|
|
315
|
+
// Batch dispose materials (do not touch texture resources)
|
|
298
316
|
materialsToDispose.forEach(mat => {
|
|
299
317
|
try {
|
|
300
318
|
mat.dispose();
|
|
301
319
|
}
|
|
302
320
|
catch (error) {
|
|
303
|
-
console.error('ArrowGuide:
|
|
321
|
+
console.error('ArrowGuide: Failed to dispose material', error);
|
|
304
322
|
}
|
|
305
323
|
});
|
|
306
|
-
//
|
|
324
|
+
// Create new WeakMap (equivalent to clearing)
|
|
307
325
|
this.fadedMaterials = new WeakMap();
|
|
308
326
|
}
|
|
309
327
|
catch (error) {
|
|
310
|
-
console.error('ArrowGuide:
|
|
328
|
+
console.error('ArrowGuide: Failed to restore material', error);
|
|
311
329
|
}
|
|
312
330
|
}
|
|
313
331
|
/**
|
|
314
|
-
*
|
|
332
|
+
* Animation update (called every frame)
|
|
315
333
|
*/
|
|
316
334
|
animate() {
|
|
317
335
|
if (!this.flowActive || !this.lxMesh)
|
|
@@ -325,16 +343,16 @@ class ArrowGuide {
|
|
|
325
343
|
}
|
|
326
344
|
}
|
|
327
345
|
catch (error) {
|
|
328
|
-
console.error('ArrowGuide:
|
|
346
|
+
console.error('ArrowGuide: Animation update failed', error);
|
|
329
347
|
}
|
|
330
348
|
}
|
|
331
349
|
/**
|
|
332
|
-
*
|
|
350
|
+
* Initialize event listeners
|
|
333
351
|
*/
|
|
334
352
|
initEvents() {
|
|
335
353
|
const dom = this.renderer.domElement;
|
|
336
354
|
const signal = this.abortController.signal;
|
|
337
|
-
//
|
|
355
|
+
// Use AbortController signal to automatically manage event lifecycle
|
|
338
356
|
dom.addEventListener('pointerdown', (e) => {
|
|
339
357
|
this.pointerDownPos.set(e.clientX, e.clientY);
|
|
340
358
|
}, { signal });
|
|
@@ -342,7 +360,7 @@ class ArrowGuide {
|
|
|
342
360
|
const dx = Math.abs(e.clientX - this.pointerDownPos.x);
|
|
343
361
|
const dy = Math.abs(e.clientY - this.pointerDownPos.y);
|
|
344
362
|
if (dx > this.clickThreshold || dy > this.clickThreshold)
|
|
345
|
-
return; //
|
|
363
|
+
return; // Dragging
|
|
346
364
|
const rect = dom.getBoundingClientRect();
|
|
347
365
|
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
348
366
|
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
@@ -356,21 +374,21 @@ class ArrowGuide {
|
|
|
356
374
|
return true;
|
|
357
375
|
});
|
|
358
376
|
if (filtered.length === 0)
|
|
359
|
-
this.restore(); //
|
|
377
|
+
this.restore(); // Click blank space to restore
|
|
360
378
|
}, { signal });
|
|
361
379
|
}
|
|
362
380
|
/**
|
|
363
|
-
*
|
|
381
|
+
* Dispose all resources
|
|
364
382
|
*/
|
|
365
383
|
dispose() {
|
|
366
|
-
//
|
|
384
|
+
// Restore materials first
|
|
367
385
|
this.restore();
|
|
368
|
-
//
|
|
386
|
+
// Unbind all events at once using AbortController
|
|
369
387
|
if (this.abortController) {
|
|
370
388
|
this.abortController.abort();
|
|
371
389
|
this.abortController = null;
|
|
372
390
|
}
|
|
373
|
-
//
|
|
391
|
+
// Clear references
|
|
374
392
|
this.modelBrightArr = [];
|
|
375
393
|
this.lxMesh = null;
|
|
376
394
|
this.fadedMaterials = new WeakMap();
|
|
@@ -379,50 +397,59 @@ class ArrowGuide {
|
|
|
379
397
|
}
|
|
380
398
|
}
|
|
381
399
|
|
|
382
|
-
// utils/LiquidFillerGroup.ts
|
|
383
400
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
401
|
+
* @file liquidFiller.ts
|
|
402
|
+
* @description
|
|
403
|
+
* Liquid filling effect for single or multiple models using local clipping planes.
|
|
386
404
|
*
|
|
387
|
-
*
|
|
388
|
-
* -
|
|
389
|
-
* -
|
|
390
|
-
* -
|
|
391
|
-
|
|
392
|
-
|
|
405
|
+
* @best-practice
|
|
406
|
+
* - Use `fillTo` to animate liquid level.
|
|
407
|
+
* - Supports multiple independent liquid levels.
|
|
408
|
+
* - Call `dispose` to clean up resources and event listeners.
|
|
409
|
+
*/
|
|
410
|
+
/**
|
|
411
|
+
* LiquidFillerGroup - Optimized
|
|
412
|
+
* Supports single or multi-model liquid level animation with independent color control.
|
|
413
|
+
*
|
|
414
|
+
* Features:
|
|
415
|
+
* - Uses renderer.domElement instead of window events
|
|
416
|
+
* - Uses AbortController to manage event lifecycle
|
|
417
|
+
* - Adds error handling and boundary checks
|
|
418
|
+
* - Optimized animation management to prevent memory leaks
|
|
419
|
+
* - Comprehensive resource disposal logic
|
|
393
420
|
*/
|
|
394
421
|
class LiquidFillerGroup {
|
|
395
422
|
/**
|
|
396
|
-
*
|
|
397
|
-
* @param models
|
|
398
|
-
* @param scene
|
|
399
|
-
* @param camera
|
|
400
|
-
* @param renderer
|
|
401
|
-
* @param defaultOptions
|
|
402
|
-
* @param clickThreshold
|
|
423
|
+
* Constructor
|
|
424
|
+
* @param models Single or multiple THREE.Object3D
|
|
425
|
+
* @param scene Scene
|
|
426
|
+
* @param camera Camera
|
|
427
|
+
* @param renderer Renderer
|
|
428
|
+
* @param defaultOptions Default liquid options
|
|
429
|
+
* @param clickThreshold Click threshold in pixels
|
|
403
430
|
*/
|
|
404
431
|
constructor(models, scene, camera, renderer, defaultOptions, clickThreshold = 10) {
|
|
405
432
|
this.items = [];
|
|
406
433
|
this.raycaster = new THREE.Raycaster();
|
|
407
434
|
this.pointerDownPos = new THREE.Vector2();
|
|
408
435
|
this.clickThreshold = 10;
|
|
409
|
-
this.abortController = null; //
|
|
410
|
-
/** pointerdown
|
|
436
|
+
this.abortController = null; // Event manager
|
|
437
|
+
/** pointerdown record position */
|
|
411
438
|
this.handlePointerDown = (event) => {
|
|
412
439
|
this.pointerDownPos.set(event.clientX, event.clientY);
|
|
413
440
|
};
|
|
414
|
-
/** pointerup
|
|
441
|
+
/** pointerup check click blank, restore original material */
|
|
415
442
|
this.handlePointerUp = (event) => {
|
|
416
443
|
const dx = event.clientX - this.pointerDownPos.x;
|
|
417
444
|
const dy = event.clientY - this.pointerDownPos.y;
|
|
418
445
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
419
446
|
if (distance > this.clickThreshold)
|
|
420
|
-
return; //
|
|
421
|
-
//
|
|
447
|
+
return; // Do not trigger on drag
|
|
448
|
+
// Use renderer.domElement actual size
|
|
422
449
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
423
450
|
const pointerNDC = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
|
|
424
451
|
this.raycaster.setFromCamera(pointerNDC, this.camera);
|
|
425
|
-
//
|
|
452
|
+
// Click blank -> Restore all models
|
|
426
453
|
const intersectsAny = this.items.some(item => this.raycaster.intersectObject(item.model, true).length > 0);
|
|
427
454
|
if (!intersectsAny) {
|
|
428
455
|
this.restoreAll();
|
|
@@ -432,18 +459,17 @@ class LiquidFillerGroup {
|
|
|
432
459
|
this.camera = camera;
|
|
433
460
|
this.renderer = renderer;
|
|
434
461
|
this.clickThreshold = clickThreshold;
|
|
435
|
-
//
|
|
462
|
+
// Create AbortController for event management
|
|
436
463
|
this.abortController = new AbortController();
|
|
437
464
|
const modelArray = Array.isArray(models) ? models : [models];
|
|
438
465
|
modelArray.forEach(model => {
|
|
439
|
-
var _a, _b, _c;
|
|
440
466
|
try {
|
|
441
467
|
const options = {
|
|
442
|
-
color:
|
|
443
|
-
opacity:
|
|
444
|
-
speed:
|
|
468
|
+
color: defaultOptions?.color ?? 0x00ff00,
|
|
469
|
+
opacity: defaultOptions?.opacity ?? 0.6,
|
|
470
|
+
speed: defaultOptions?.speed ?? 0.05,
|
|
445
471
|
};
|
|
446
|
-
//
|
|
472
|
+
// Save original materials
|
|
447
473
|
const originalMaterials = new Map();
|
|
448
474
|
model.traverse(obj => {
|
|
449
475
|
if (obj.isMesh) {
|
|
@@ -451,12 +477,12 @@ class LiquidFillerGroup {
|
|
|
451
477
|
originalMaterials.set(mesh, mesh.material);
|
|
452
478
|
}
|
|
453
479
|
});
|
|
454
|
-
//
|
|
480
|
+
// Boundary check: ensure there are materials to save
|
|
455
481
|
if (originalMaterials.size === 0) {
|
|
456
|
-
console.warn('LiquidFillerGroup:
|
|
482
|
+
console.warn('LiquidFillerGroup: Model has no Mesh objects', model);
|
|
457
483
|
return;
|
|
458
484
|
}
|
|
459
|
-
//
|
|
485
|
+
// Apply faded wireframe material
|
|
460
486
|
model.traverse(obj => {
|
|
461
487
|
if (obj.isMesh) {
|
|
462
488
|
const mesh = obj;
|
|
@@ -468,7 +494,7 @@ class LiquidFillerGroup {
|
|
|
468
494
|
});
|
|
469
495
|
}
|
|
470
496
|
});
|
|
471
|
-
//
|
|
497
|
+
// Create liquid Mesh
|
|
472
498
|
const geometries = [];
|
|
473
499
|
model.traverse(obj => {
|
|
474
500
|
if (obj.isMesh) {
|
|
@@ -479,12 +505,12 @@ class LiquidFillerGroup {
|
|
|
479
505
|
}
|
|
480
506
|
});
|
|
481
507
|
if (geometries.length === 0) {
|
|
482
|
-
console.warn('LiquidFillerGroup:
|
|
508
|
+
console.warn('LiquidFillerGroup: Model has no geometries', model);
|
|
483
509
|
return;
|
|
484
510
|
}
|
|
485
511
|
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries, false);
|
|
486
512
|
if (!mergedGeometry) {
|
|
487
|
-
console.error('LiquidFillerGroup:
|
|
513
|
+
console.error('LiquidFillerGroup: Failed to merge geometries', model);
|
|
488
514
|
return;
|
|
489
515
|
}
|
|
490
516
|
const material = new THREE.MeshPhongMaterial({
|
|
@@ -495,7 +521,7 @@ class LiquidFillerGroup {
|
|
|
495
521
|
});
|
|
496
522
|
const liquidMesh = new THREE.Mesh(mergedGeometry, material);
|
|
497
523
|
this.scene.add(liquidMesh);
|
|
498
|
-
//
|
|
524
|
+
// Set clippingPlane
|
|
499
525
|
const clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
|
500
526
|
const mat = liquidMesh.material;
|
|
501
527
|
mat.clippingPlanes = [clipPlane];
|
|
@@ -506,41 +532,41 @@ class LiquidFillerGroup {
|
|
|
506
532
|
clipPlane,
|
|
507
533
|
originalMaterials,
|
|
508
534
|
options,
|
|
509
|
-
animationId: null //
|
|
535
|
+
animationId: null // Initialize animation ID
|
|
510
536
|
});
|
|
511
537
|
}
|
|
512
538
|
catch (error) {
|
|
513
|
-
console.error('LiquidFillerGroup:
|
|
539
|
+
console.error('LiquidFillerGroup: Failed to initialize model', model, error);
|
|
514
540
|
}
|
|
515
541
|
});
|
|
516
|
-
//
|
|
542
|
+
// Use renderer.domElement instead of window, use AbortController signal
|
|
517
543
|
const signal = this.abortController.signal;
|
|
518
544
|
this.renderer.domElement.addEventListener('pointerdown', this.handlePointerDown, { signal });
|
|
519
545
|
this.renderer.domElement.addEventListener('pointerup', this.handlePointerUp, { signal });
|
|
520
546
|
}
|
|
521
547
|
/**
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
548
|
+
* Set liquid level
|
|
549
|
+
* @param models Single model or array of models
|
|
550
|
+
* @param percent Liquid level percentage 0~1
|
|
551
|
+
*/
|
|
526
552
|
fillTo(models, percent) {
|
|
527
|
-
//
|
|
553
|
+
// Boundary check
|
|
528
554
|
if (percent < 0 || percent > 1) {
|
|
529
|
-
console.warn('LiquidFillerGroup: percent
|
|
555
|
+
console.warn('LiquidFillerGroup: percent must be between 0 and 1', percent);
|
|
530
556
|
percent = Math.max(0, Math.min(1, percent));
|
|
531
557
|
}
|
|
532
558
|
const modelArray = Array.isArray(models) ? models : [models];
|
|
533
559
|
modelArray.forEach(model => {
|
|
534
560
|
const item = this.items.find(i => i.model === model);
|
|
535
561
|
if (!item) {
|
|
536
|
-
console.warn('LiquidFillerGroup:
|
|
562
|
+
console.warn('LiquidFillerGroup: Model not found', model);
|
|
537
563
|
return;
|
|
538
564
|
}
|
|
539
565
|
if (!item.liquidMesh) {
|
|
540
|
-
console.warn('LiquidFillerGroup: liquidMesh
|
|
566
|
+
console.warn('LiquidFillerGroup: liquidMesh already disposed', model);
|
|
541
567
|
return;
|
|
542
568
|
}
|
|
543
|
-
//
|
|
569
|
+
// Cancel previous animation
|
|
544
570
|
if (item.animationId !== null) {
|
|
545
571
|
cancelAnimationFrame(item.animationId);
|
|
546
572
|
item.animationId = null;
|
|
@@ -568,14 +594,14 @@ class LiquidFillerGroup {
|
|
|
568
594
|
animate();
|
|
569
595
|
}
|
|
570
596
|
catch (error) {
|
|
571
|
-
console.error('LiquidFillerGroup: fillTo
|
|
597
|
+
console.error('LiquidFillerGroup: fillTo execution failed', model, error);
|
|
572
598
|
}
|
|
573
599
|
});
|
|
574
600
|
}
|
|
575
|
-
/**
|
|
601
|
+
/** Set multiple model levels, percentList corresponds to items order */
|
|
576
602
|
fillToAll(percentList) {
|
|
577
603
|
if (percentList.length !== this.items.length) {
|
|
578
|
-
console.warn(`LiquidFillerGroup: percentList
|
|
604
|
+
console.warn(`LiquidFillerGroup: percentList length (${percentList.length}) does not match items length (${this.items.length})`);
|
|
579
605
|
}
|
|
580
606
|
percentList.forEach((p, idx) => {
|
|
581
607
|
if (idx < this.items.length) {
|
|
@@ -583,17 +609,17 @@ class LiquidFillerGroup {
|
|
|
583
609
|
}
|
|
584
610
|
});
|
|
585
611
|
}
|
|
586
|
-
/**
|
|
612
|
+
/** Restore single model original material and remove liquid */
|
|
587
613
|
restore(model) {
|
|
588
614
|
const item = this.items.find(i => i.model === model);
|
|
589
615
|
if (!item)
|
|
590
616
|
return;
|
|
591
|
-
//
|
|
617
|
+
// Cancel animation
|
|
592
618
|
if (item.animationId !== null) {
|
|
593
619
|
cancelAnimationFrame(item.animationId);
|
|
594
620
|
item.animationId = null;
|
|
595
621
|
}
|
|
596
|
-
//
|
|
622
|
+
// Restore original material
|
|
597
623
|
item.model.traverse(obj => {
|
|
598
624
|
if (obj.isMesh) {
|
|
599
625
|
const mesh = obj;
|
|
@@ -602,7 +628,7 @@ class LiquidFillerGroup {
|
|
|
602
628
|
mesh.material = original;
|
|
603
629
|
}
|
|
604
630
|
});
|
|
605
|
-
//
|
|
631
|
+
// Dispose liquid Mesh
|
|
606
632
|
if (item.liquidMesh) {
|
|
607
633
|
this.scene.remove(item.liquidMesh);
|
|
608
634
|
item.liquidMesh.geometry.dispose();
|
|
@@ -615,20 +641,20 @@ class LiquidFillerGroup {
|
|
|
615
641
|
item.liquidMesh = null;
|
|
616
642
|
}
|
|
617
643
|
}
|
|
618
|
-
/**
|
|
644
|
+
/** Restore all models */
|
|
619
645
|
restoreAll() {
|
|
620
646
|
this.items.forEach(item => this.restore(item.model));
|
|
621
647
|
}
|
|
622
|
-
/**
|
|
648
|
+
/** Dispose method, release events and resources */
|
|
623
649
|
dispose() {
|
|
624
|
-
//
|
|
650
|
+
// Restore all models first
|
|
625
651
|
this.restoreAll();
|
|
626
|
-
//
|
|
652
|
+
// Unbind all events at once using AbortController
|
|
627
653
|
if (this.abortController) {
|
|
628
654
|
this.abortController.abort();
|
|
629
655
|
this.abortController = null;
|
|
630
656
|
}
|
|
631
|
-
//
|
|
657
|
+
// Clear items
|
|
632
658
|
this.items.length = 0;
|
|
633
659
|
}
|
|
634
660
|
}
|