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