@chocozhang/three-model-render 1.0.2 → 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 +149 -530
- 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 +1 -1
|
@@ -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,55 +91,64 @@ 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) {
|
|
@@ -144,12 +163,12 @@ class ArrowGuide {
|
|
|
144
163
|
this.clickThreshold = 10;
|
|
145
164
|
this.raycaster = new THREE.Raycaster();
|
|
146
165
|
this.mouse = new THREE.Vector2();
|
|
147
|
-
//
|
|
166
|
+
// Use WeakMap for automatic material recycling (GC friendly)
|
|
148
167
|
this.originalMaterials = new WeakMap();
|
|
149
168
|
this.fadedMaterials = new WeakMap();
|
|
150
|
-
//
|
|
169
|
+
// AbortController for event management
|
|
151
170
|
this.abortController = null;
|
|
152
|
-
//
|
|
171
|
+
// Config: Non-highlight opacity and brightness
|
|
153
172
|
this.fadeOpacity = 0.5;
|
|
154
173
|
this.fadeBrightness = 0.1;
|
|
155
174
|
this.clickThreshold = (_a = options === null || options === void 0 ? void 0 : options.clickThreshold) !== null && _a !== void 0 ? _a : 10;
|
|
@@ -159,30 +178,30 @@ class ArrowGuide {
|
|
|
159
178
|
this.abortController = new AbortController();
|
|
160
179
|
this.initEvents();
|
|
161
180
|
}
|
|
162
|
-
//
|
|
181
|
+
// Tool: Cache original material (first time only)
|
|
163
182
|
cacheOriginalMaterial(mesh) {
|
|
164
183
|
if (!this.originalMaterials.has(mesh)) {
|
|
165
184
|
this.originalMaterials.set(mesh, mesh.material);
|
|
166
185
|
}
|
|
167
186
|
}
|
|
168
|
-
//
|
|
187
|
+
// Tool: Clone a "translucent version" for a material, preserving all maps and parameters
|
|
169
188
|
makeFadedClone(mat) {
|
|
170
189
|
const clone = mat.clone();
|
|
171
190
|
const c = clone;
|
|
172
|
-
//
|
|
191
|
+
// Only modify transparency parameters, do not modify detail maps like map / normalMap / roughnessMap
|
|
173
192
|
c.transparent = true;
|
|
174
193
|
if (typeof c.opacity === 'number')
|
|
175
194
|
c.opacity = this.fadeOpacity;
|
|
176
195
|
if (c.color && c.color.isColor) {
|
|
177
|
-
c.color.multiplyScalar(this.fadeBrightness); //
|
|
196
|
+
c.color.multiplyScalar(this.fadeBrightness); // Darken color overall
|
|
178
197
|
}
|
|
179
|
-
//
|
|
198
|
+
// Common strategy for fluid display behind transparent objects: do not write depth, only test depth
|
|
180
199
|
clone.depthWrite = false;
|
|
181
200
|
clone.depthTest = true;
|
|
182
201
|
clone.needsUpdate = true;
|
|
183
202
|
return clone;
|
|
184
203
|
}
|
|
185
|
-
//
|
|
204
|
+
// Tool: Batch clone "translucent version" for mesh.material (could be array)
|
|
186
205
|
createFadedMaterialFrom(mesh) {
|
|
187
206
|
const orig = mesh.material;
|
|
188
207
|
if (Array.isArray(orig)) {
|
|
@@ -191,7 +210,7 @@ class ArrowGuide {
|
|
|
191
210
|
return this.makeFadedClone(orig);
|
|
192
211
|
}
|
|
193
212
|
/**
|
|
194
|
-
*
|
|
213
|
+
* Set Arrow Mesh
|
|
195
214
|
*/
|
|
196
215
|
setArrowMesh(mesh) {
|
|
197
216
|
this.lxMesh = mesh;
|
|
@@ -207,15 +226,15 @@ class ArrowGuide {
|
|
|
207
226
|
mesh.visible = false;
|
|
208
227
|
}
|
|
209
228
|
catch (error) {
|
|
210
|
-
console.error('ArrowGuide:
|
|
229
|
+
console.error('ArrowGuide: Failed to set arrow material', error);
|
|
211
230
|
}
|
|
212
231
|
}
|
|
213
232
|
/**
|
|
214
|
-
*
|
|
233
|
+
* Highlight specified models
|
|
215
234
|
*/
|
|
216
235
|
highlight(models) {
|
|
217
236
|
if (!models || models.length === 0) {
|
|
218
|
-
console.warn('ArrowGuide:
|
|
237
|
+
console.warn('ArrowGuide: Highlight model list is empty');
|
|
219
238
|
return;
|
|
220
239
|
}
|
|
221
240
|
this.modelBrightArr = models;
|
|
@@ -224,9 +243,9 @@ class ArrowGuide {
|
|
|
224
243
|
this.lxMesh.visible = true;
|
|
225
244
|
this.applyHighlight();
|
|
226
245
|
}
|
|
227
|
-
//
|
|
246
|
+
// Apply highlight effect: Non-highlighted models preserve details -> use "cloned translucent material"
|
|
228
247
|
applyHighlight() {
|
|
229
|
-
//
|
|
248
|
+
// Use Set to improve lookup performance
|
|
230
249
|
const keepMeshes = new Set();
|
|
231
250
|
this.modelBrightArr.forEach(obj => {
|
|
232
251
|
obj.traverse(child => {
|
|
@@ -238,21 +257,21 @@ class ArrowGuide {
|
|
|
238
257
|
this.scene.traverse(obj => {
|
|
239
258
|
if (obj.isMesh) {
|
|
240
259
|
const mesh = obj;
|
|
241
|
-
//
|
|
260
|
+
// Cache original material (for restoration)
|
|
242
261
|
this.cacheOriginalMaterial(mesh);
|
|
243
262
|
if (!keepMeshes.has(mesh)) {
|
|
244
|
-
//
|
|
263
|
+
// Non-highlighted: if no "translucent clone material" generated yet, create one
|
|
245
264
|
if (!this.fadedMaterials.has(mesh)) {
|
|
246
265
|
const faded = this.createFadedMaterialFrom(mesh);
|
|
247
266
|
this.fadedMaterials.set(mesh, faded);
|
|
248
267
|
}
|
|
249
|
-
//
|
|
268
|
+
// Replace with clone material (preserve all maps/normals details)
|
|
250
269
|
const fadedMat = this.fadedMaterials.get(mesh);
|
|
251
270
|
if (fadedMat)
|
|
252
271
|
mesh.material = fadedMat;
|
|
253
272
|
}
|
|
254
273
|
else {
|
|
255
|
-
//
|
|
274
|
+
// Highlighted object: ensure return to original material (avoid leftover from previous highlight)
|
|
256
275
|
const orig = this.originalMaterials.get(mesh);
|
|
257
276
|
if (orig && mesh.material !== orig) {
|
|
258
277
|
mesh.material = orig;
|
|
@@ -263,16 +282,16 @@ class ArrowGuide {
|
|
|
263
282
|
});
|
|
264
283
|
}
|
|
265
284
|
catch (error) {
|
|
266
|
-
console.error('ArrowGuide:
|
|
285
|
+
console.error('ArrowGuide: Failed to apply highlight', error);
|
|
267
286
|
}
|
|
268
287
|
}
|
|
269
|
-
//
|
|
288
|
+
// Restore to original material & dispose clone material
|
|
270
289
|
restore() {
|
|
271
290
|
this.flowActive = false;
|
|
272
291
|
if (this.lxMesh)
|
|
273
292
|
this.lxMesh.visible = false;
|
|
274
293
|
try {
|
|
275
|
-
//
|
|
294
|
+
// Collect all materials to dispose
|
|
276
295
|
const materialsToDispose = [];
|
|
277
296
|
this.scene.traverse(obj => {
|
|
278
297
|
if (obj.isMesh) {
|
|
@@ -282,7 +301,7 @@ class ArrowGuide {
|
|
|
282
301
|
mesh.material = orig;
|
|
283
302
|
mesh.material.needsUpdate = true;
|
|
284
303
|
}
|
|
285
|
-
//
|
|
304
|
+
// Collect faded materials to dispose
|
|
286
305
|
const faded = this.fadedMaterials.get(mesh);
|
|
287
306
|
if (faded) {
|
|
288
307
|
if (Array.isArray(faded)) {
|
|
@@ -294,24 +313,24 @@ class ArrowGuide {
|
|
|
294
313
|
}
|
|
295
314
|
}
|
|
296
315
|
});
|
|
297
|
-
//
|
|
316
|
+
// Batch dispose materials (do not touch texture resources)
|
|
298
317
|
materialsToDispose.forEach(mat => {
|
|
299
318
|
try {
|
|
300
319
|
mat.dispose();
|
|
301
320
|
}
|
|
302
321
|
catch (error) {
|
|
303
|
-
console.error('ArrowGuide:
|
|
322
|
+
console.error('ArrowGuide: Failed to dispose material', error);
|
|
304
323
|
}
|
|
305
324
|
});
|
|
306
|
-
//
|
|
325
|
+
// Create new WeakMap (equivalent to clearing)
|
|
307
326
|
this.fadedMaterials = new WeakMap();
|
|
308
327
|
}
|
|
309
328
|
catch (error) {
|
|
310
|
-
console.error('ArrowGuide:
|
|
329
|
+
console.error('ArrowGuide: Failed to restore material', error);
|
|
311
330
|
}
|
|
312
331
|
}
|
|
313
332
|
/**
|
|
314
|
-
*
|
|
333
|
+
* Animation update (called every frame)
|
|
315
334
|
*/
|
|
316
335
|
animate() {
|
|
317
336
|
if (!this.flowActive || !this.lxMesh)
|
|
@@ -325,16 +344,16 @@ class ArrowGuide {
|
|
|
325
344
|
}
|
|
326
345
|
}
|
|
327
346
|
catch (error) {
|
|
328
|
-
console.error('ArrowGuide:
|
|
347
|
+
console.error('ArrowGuide: Animation update failed', error);
|
|
329
348
|
}
|
|
330
349
|
}
|
|
331
350
|
/**
|
|
332
|
-
*
|
|
351
|
+
* Initialize event listeners
|
|
333
352
|
*/
|
|
334
353
|
initEvents() {
|
|
335
354
|
const dom = this.renderer.domElement;
|
|
336
355
|
const signal = this.abortController.signal;
|
|
337
|
-
//
|
|
356
|
+
// Use AbortController signal to automatically manage event lifecycle
|
|
338
357
|
dom.addEventListener('pointerdown', (e) => {
|
|
339
358
|
this.pointerDownPos.set(e.clientX, e.clientY);
|
|
340
359
|
}, { signal });
|
|
@@ -342,7 +361,7 @@ class ArrowGuide {
|
|
|
342
361
|
const dx = Math.abs(e.clientX - this.pointerDownPos.x);
|
|
343
362
|
const dy = Math.abs(e.clientY - this.pointerDownPos.y);
|
|
344
363
|
if (dx > this.clickThreshold || dy > this.clickThreshold)
|
|
345
|
-
return; //
|
|
364
|
+
return; // Dragging
|
|
346
365
|
const rect = dom.getBoundingClientRect();
|
|
347
366
|
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
348
367
|
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
@@ -356,21 +375,21 @@ class ArrowGuide {
|
|
|
356
375
|
return true;
|
|
357
376
|
});
|
|
358
377
|
if (filtered.length === 0)
|
|
359
|
-
this.restore(); //
|
|
378
|
+
this.restore(); // Click blank space to restore
|
|
360
379
|
}, { signal });
|
|
361
380
|
}
|
|
362
381
|
/**
|
|
363
|
-
*
|
|
382
|
+
* Dispose all resources
|
|
364
383
|
*/
|
|
365
384
|
dispose() {
|
|
366
|
-
//
|
|
385
|
+
// Restore materials first
|
|
367
386
|
this.restore();
|
|
368
|
-
//
|
|
387
|
+
// Unbind all events at once using AbortController
|
|
369
388
|
if (this.abortController) {
|
|
370
389
|
this.abortController.abort();
|
|
371
390
|
this.abortController = null;
|
|
372
391
|
}
|
|
373
|
-
//
|
|
392
|
+
// Clear references
|
|
374
393
|
this.modelBrightArr = [];
|
|
375
394
|
this.lxMesh = null;
|
|
376
395
|
this.fadedMaterials = new WeakMap();
|
|
@@ -379,50 +398,59 @@ class ArrowGuide {
|
|
|
379
398
|
}
|
|
380
399
|
}
|
|
381
400
|
|
|
382
|
-
// utils/LiquidFillerGroup.ts
|
|
383
401
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
402
|
+
* @file liquidFiller.ts
|
|
403
|
+
* @description
|
|
404
|
+
* Liquid filling effect for single or multiple models using local clipping planes.
|
|
386
405
|
*
|
|
387
|
-
*
|
|
388
|
-
* -
|
|
389
|
-
* -
|
|
390
|
-
* -
|
|
391
|
-
|
|
392
|
-
|
|
406
|
+
* @best-practice
|
|
407
|
+
* - Use `fillTo` to animate liquid level.
|
|
408
|
+
* - Supports multiple independent liquid levels.
|
|
409
|
+
* - Call `dispose` to clean up resources and event listeners.
|
|
410
|
+
*/
|
|
411
|
+
/**
|
|
412
|
+
* LiquidFillerGroup - Optimized
|
|
413
|
+
* Supports single or multi-model liquid level animation with independent color control.
|
|
414
|
+
*
|
|
415
|
+
* Features:
|
|
416
|
+
* - Uses renderer.domElement instead of window events
|
|
417
|
+
* - Uses AbortController to manage event lifecycle
|
|
418
|
+
* - Adds error handling and boundary checks
|
|
419
|
+
* - Optimized animation management to prevent memory leaks
|
|
420
|
+
* - Comprehensive resource disposal logic
|
|
393
421
|
*/
|
|
394
422
|
class LiquidFillerGroup {
|
|
395
423
|
/**
|
|
396
|
-
*
|
|
397
|
-
* @param models
|
|
398
|
-
* @param scene
|
|
399
|
-
* @param camera
|
|
400
|
-
* @param renderer
|
|
401
|
-
* @param defaultOptions
|
|
402
|
-
* @param clickThreshold
|
|
424
|
+
* Constructor
|
|
425
|
+
* @param models Single or multiple THREE.Object3D
|
|
426
|
+
* @param scene Scene
|
|
427
|
+
* @param camera Camera
|
|
428
|
+
* @param renderer Renderer
|
|
429
|
+
* @param defaultOptions Default liquid options
|
|
430
|
+
* @param clickThreshold Click threshold in pixels
|
|
403
431
|
*/
|
|
404
432
|
constructor(models, scene, camera, renderer, defaultOptions, clickThreshold = 10) {
|
|
405
433
|
this.items = [];
|
|
406
434
|
this.raycaster = new THREE.Raycaster();
|
|
407
435
|
this.pointerDownPos = new THREE.Vector2();
|
|
408
436
|
this.clickThreshold = 10;
|
|
409
|
-
this.abortController = null; //
|
|
410
|
-
/** pointerdown
|
|
437
|
+
this.abortController = null; // Event manager
|
|
438
|
+
/** pointerdown record position */
|
|
411
439
|
this.handlePointerDown = (event) => {
|
|
412
440
|
this.pointerDownPos.set(event.clientX, event.clientY);
|
|
413
441
|
};
|
|
414
|
-
/** pointerup
|
|
442
|
+
/** pointerup check click blank, restore original material */
|
|
415
443
|
this.handlePointerUp = (event) => {
|
|
416
444
|
const dx = event.clientX - this.pointerDownPos.x;
|
|
417
445
|
const dy = event.clientY - this.pointerDownPos.y;
|
|
418
446
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
419
447
|
if (distance > this.clickThreshold)
|
|
420
|
-
return; //
|
|
421
|
-
//
|
|
448
|
+
return; // Do not trigger on drag
|
|
449
|
+
// Use renderer.domElement actual size
|
|
422
450
|
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
423
451
|
const pointerNDC = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
|
|
424
452
|
this.raycaster.setFromCamera(pointerNDC, this.camera);
|
|
425
|
-
//
|
|
453
|
+
// Click blank -> Restore all models
|
|
426
454
|
const intersectsAny = this.items.some(item => this.raycaster.intersectObject(item.model, true).length > 0);
|
|
427
455
|
if (!intersectsAny) {
|
|
428
456
|
this.restoreAll();
|
|
@@ -432,7 +460,7 @@ class LiquidFillerGroup {
|
|
|
432
460
|
this.camera = camera;
|
|
433
461
|
this.renderer = renderer;
|
|
434
462
|
this.clickThreshold = clickThreshold;
|
|
435
|
-
//
|
|
463
|
+
// Create AbortController for event management
|
|
436
464
|
this.abortController = new AbortController();
|
|
437
465
|
const modelArray = Array.isArray(models) ? models : [models];
|
|
438
466
|
modelArray.forEach(model => {
|
|
@@ -443,7 +471,7 @@ class LiquidFillerGroup {
|
|
|
443
471
|
opacity: (_b = defaultOptions === null || defaultOptions === void 0 ? void 0 : defaultOptions.opacity) !== null && _b !== void 0 ? _b : 0.6,
|
|
444
472
|
speed: (_c = defaultOptions === null || defaultOptions === void 0 ? void 0 : defaultOptions.speed) !== null && _c !== void 0 ? _c : 0.05,
|
|
445
473
|
};
|
|
446
|
-
//
|
|
474
|
+
// Save original materials
|
|
447
475
|
const originalMaterials = new Map();
|
|
448
476
|
model.traverse(obj => {
|
|
449
477
|
if (obj.isMesh) {
|
|
@@ -451,12 +479,12 @@ class LiquidFillerGroup {
|
|
|
451
479
|
originalMaterials.set(mesh, mesh.material);
|
|
452
480
|
}
|
|
453
481
|
});
|
|
454
|
-
//
|
|
482
|
+
// Boundary check: ensure there are materials to save
|
|
455
483
|
if (originalMaterials.size === 0) {
|
|
456
|
-
console.warn('LiquidFillerGroup:
|
|
484
|
+
console.warn('LiquidFillerGroup: Model has no Mesh objects', model);
|
|
457
485
|
return;
|
|
458
486
|
}
|
|
459
|
-
//
|
|
487
|
+
// Apply faded wireframe material
|
|
460
488
|
model.traverse(obj => {
|
|
461
489
|
if (obj.isMesh) {
|
|
462
490
|
const mesh = obj;
|
|
@@ -468,7 +496,7 @@ class LiquidFillerGroup {
|
|
|
468
496
|
});
|
|
469
497
|
}
|
|
470
498
|
});
|
|
471
|
-
//
|
|
499
|
+
// Create liquid Mesh
|
|
472
500
|
const geometries = [];
|
|
473
501
|
model.traverse(obj => {
|
|
474
502
|
if (obj.isMesh) {
|
|
@@ -479,12 +507,12 @@ class LiquidFillerGroup {
|
|
|
479
507
|
}
|
|
480
508
|
});
|
|
481
509
|
if (geometries.length === 0) {
|
|
482
|
-
console.warn('LiquidFillerGroup:
|
|
510
|
+
console.warn('LiquidFillerGroup: Model has no geometries', model);
|
|
483
511
|
return;
|
|
484
512
|
}
|
|
485
513
|
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries, false);
|
|
486
514
|
if (!mergedGeometry) {
|
|
487
|
-
console.error('LiquidFillerGroup:
|
|
515
|
+
console.error('LiquidFillerGroup: Failed to merge geometries', model);
|
|
488
516
|
return;
|
|
489
517
|
}
|
|
490
518
|
const material = new THREE.MeshPhongMaterial({
|
|
@@ -495,7 +523,7 @@ class LiquidFillerGroup {
|
|
|
495
523
|
});
|
|
496
524
|
const liquidMesh = new THREE.Mesh(mergedGeometry, material);
|
|
497
525
|
this.scene.add(liquidMesh);
|
|
498
|
-
//
|
|
526
|
+
// Set clippingPlane
|
|
499
527
|
const clipPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0);
|
|
500
528
|
const mat = liquidMesh.material;
|
|
501
529
|
mat.clippingPlanes = [clipPlane];
|
|
@@ -506,41 +534,41 @@ class LiquidFillerGroup {
|
|
|
506
534
|
clipPlane,
|
|
507
535
|
originalMaterials,
|
|
508
536
|
options,
|
|
509
|
-
animationId: null //
|
|
537
|
+
animationId: null // Initialize animation ID
|
|
510
538
|
});
|
|
511
539
|
}
|
|
512
540
|
catch (error) {
|
|
513
|
-
console.error('LiquidFillerGroup:
|
|
541
|
+
console.error('LiquidFillerGroup: Failed to initialize model', model, error);
|
|
514
542
|
}
|
|
515
543
|
});
|
|
516
|
-
//
|
|
544
|
+
// Use renderer.domElement instead of window, use AbortController signal
|
|
517
545
|
const signal = this.abortController.signal;
|
|
518
546
|
this.renderer.domElement.addEventListener('pointerdown', this.handlePointerDown, { signal });
|
|
519
547
|
this.renderer.domElement.addEventListener('pointerup', this.handlePointerUp, { signal });
|
|
520
548
|
}
|
|
521
549
|
/**
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
550
|
+
* Set liquid level
|
|
551
|
+
* @param models Single model or array of models
|
|
552
|
+
* @param percent Liquid level percentage 0~1
|
|
553
|
+
*/
|
|
526
554
|
fillTo(models, percent) {
|
|
527
|
-
//
|
|
555
|
+
// Boundary check
|
|
528
556
|
if (percent < 0 || percent > 1) {
|
|
529
|
-
console.warn('LiquidFillerGroup: percent
|
|
557
|
+
console.warn('LiquidFillerGroup: percent must be between 0 and 1', percent);
|
|
530
558
|
percent = Math.max(0, Math.min(1, percent));
|
|
531
559
|
}
|
|
532
560
|
const modelArray = Array.isArray(models) ? models : [models];
|
|
533
561
|
modelArray.forEach(model => {
|
|
534
562
|
const item = this.items.find(i => i.model === model);
|
|
535
563
|
if (!item) {
|
|
536
|
-
console.warn('LiquidFillerGroup:
|
|
564
|
+
console.warn('LiquidFillerGroup: Model not found', model);
|
|
537
565
|
return;
|
|
538
566
|
}
|
|
539
567
|
if (!item.liquidMesh) {
|
|
540
|
-
console.warn('LiquidFillerGroup: liquidMesh
|
|
568
|
+
console.warn('LiquidFillerGroup: liquidMesh already disposed', model);
|
|
541
569
|
return;
|
|
542
570
|
}
|
|
543
|
-
//
|
|
571
|
+
// Cancel previous animation
|
|
544
572
|
if (item.animationId !== null) {
|
|
545
573
|
cancelAnimationFrame(item.animationId);
|
|
546
574
|
item.animationId = null;
|
|
@@ -568,14 +596,14 @@ class LiquidFillerGroup {
|
|
|
568
596
|
animate();
|
|
569
597
|
}
|
|
570
598
|
catch (error) {
|
|
571
|
-
console.error('LiquidFillerGroup: fillTo
|
|
599
|
+
console.error('LiquidFillerGroup: fillTo execution failed', model, error);
|
|
572
600
|
}
|
|
573
601
|
});
|
|
574
602
|
}
|
|
575
|
-
/**
|
|
603
|
+
/** Set multiple model levels, percentList corresponds to items order */
|
|
576
604
|
fillToAll(percentList) {
|
|
577
605
|
if (percentList.length !== this.items.length) {
|
|
578
|
-
console.warn(`LiquidFillerGroup: percentList
|
|
606
|
+
console.warn(`LiquidFillerGroup: percentList length (${percentList.length}) does not match items length (${this.items.length})`);
|
|
579
607
|
}
|
|
580
608
|
percentList.forEach((p, idx) => {
|
|
581
609
|
if (idx < this.items.length) {
|
|
@@ -583,17 +611,17 @@ class LiquidFillerGroup {
|
|
|
583
611
|
}
|
|
584
612
|
});
|
|
585
613
|
}
|
|
586
|
-
/**
|
|
614
|
+
/** Restore single model original material and remove liquid */
|
|
587
615
|
restore(model) {
|
|
588
616
|
const item = this.items.find(i => i.model === model);
|
|
589
617
|
if (!item)
|
|
590
618
|
return;
|
|
591
|
-
//
|
|
619
|
+
// Cancel animation
|
|
592
620
|
if (item.animationId !== null) {
|
|
593
621
|
cancelAnimationFrame(item.animationId);
|
|
594
622
|
item.animationId = null;
|
|
595
623
|
}
|
|
596
|
-
//
|
|
624
|
+
// Restore original material
|
|
597
625
|
item.model.traverse(obj => {
|
|
598
626
|
if (obj.isMesh) {
|
|
599
627
|
const mesh = obj;
|
|
@@ -602,7 +630,7 @@ class LiquidFillerGroup {
|
|
|
602
630
|
mesh.material = original;
|
|
603
631
|
}
|
|
604
632
|
});
|
|
605
|
-
//
|
|
633
|
+
// Dispose liquid Mesh
|
|
606
634
|
if (item.liquidMesh) {
|
|
607
635
|
this.scene.remove(item.liquidMesh);
|
|
608
636
|
item.liquidMesh.geometry.dispose();
|
|
@@ -615,20 +643,20 @@ class LiquidFillerGroup {
|
|
|
615
643
|
item.liquidMesh = null;
|
|
616
644
|
}
|
|
617
645
|
}
|
|
618
|
-
/**
|
|
646
|
+
/** Restore all models */
|
|
619
647
|
restoreAll() {
|
|
620
648
|
this.items.forEach(item => this.restore(item.model));
|
|
621
649
|
}
|
|
622
|
-
/**
|
|
650
|
+
/** Dispose method, release events and resources */
|
|
623
651
|
dispose() {
|
|
624
|
-
//
|
|
652
|
+
// Restore all models first
|
|
625
653
|
this.restoreAll();
|
|
626
|
-
//
|
|
654
|
+
// Unbind all events at once using AbortController
|
|
627
655
|
if (this.abortController) {
|
|
628
656
|
this.abortController.abort();
|
|
629
657
|
this.abortController = null;
|
|
630
658
|
}
|
|
631
|
-
//
|
|
659
|
+
// Clear items
|
|
632
660
|
this.items.length = 0;
|
|
633
661
|
}
|
|
634
662
|
}
|