@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
package/dist/effect/index.mjs
CHANGED
|
@@ -1,41 +1,33 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
22
|
-
return new (P || (P = Promise))(function (resolve, reject) {
|
|
23
|
-
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
24
|
-
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
25
|
-
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
26
|
-
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
31
|
-
var e = new Error(message);
|
|
32
|
-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
33
|
-
};
|
|
34
|
-
|
|
3
|
+
/**
|
|
4
|
+
* @file exploder.ts
|
|
5
|
+
* @description
|
|
6
|
+
* GroupExploder - Three.js based model explosion effect tool (Vue3 + TS Support)
|
|
7
|
+
* ----------------------------------------------------------------------
|
|
8
|
+
* This tool is used to perform "explode / restore" animations on a set of specified Meshes:
|
|
9
|
+
* - Initialize only once (onMounted)
|
|
10
|
+
* - Supports dynamic switching of models and automatically restores the explosion state of the previous model
|
|
11
|
+
* - Supports multiple arrangement modes (ring / spiral / grid / radial)
|
|
12
|
+
* - Supports automatic transparency for non-exploded objects (dimOthers)
|
|
13
|
+
* - Supports automatic camera positioning to the best observation point
|
|
14
|
+
* - All animations use native requestAnimationFrame
|
|
15
|
+
*
|
|
16
|
+
* @best-practice
|
|
17
|
+
* - Initialize in `onMounted`.
|
|
18
|
+
* - Use `setMeshes` to update the active set of meshes to explode.
|
|
19
|
+
* - Call `explode()` to trigger the effect and `restore()` to reset.
|
|
20
|
+
*/
|
|
35
21
|
function easeInOutQuad(t) {
|
|
36
22
|
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
37
23
|
}
|
|
38
24
|
class GroupExploder {
|
|
25
|
+
/**
|
|
26
|
+
* Constructor
|
|
27
|
+
* @param scene Three.js Scene instance
|
|
28
|
+
* @param camera Three.js Camera (usually PerspectiveCamera)
|
|
29
|
+
* @param controls OrbitControls instance (must be bound to camera)
|
|
30
|
+
*/
|
|
39
31
|
constructor(scene, camera, controls) {
|
|
40
32
|
// sets and snapshots
|
|
41
33
|
this.currentSet = null;
|
|
@@ -67,211 +59,210 @@ class GroupExploder {
|
|
|
67
59
|
this.log('init() called');
|
|
68
60
|
}
|
|
69
61
|
/**
|
|
70
|
-
*
|
|
62
|
+
* Set the current set of meshes for explosion.
|
|
71
63
|
* - Detects content-level changes even if same Set reference is used.
|
|
72
64
|
* - Preserves prevSet/stateMap to allow async restore when needed.
|
|
73
65
|
* - Ensures stateMap contains snapshots for *all meshes in the new set*.
|
|
66
|
+
* @param newSet The new set of meshes
|
|
67
|
+
* @param contextId Optional context ID to distinguish business scenarios
|
|
74
68
|
*/
|
|
75
|
-
setMeshes(newSet, options) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
69
|
+
async setMeshes(newSet, options) {
|
|
70
|
+
const autoRestorePrev = options?.autoRestorePrev ?? true;
|
|
71
|
+
const restoreDuration = options?.restoreDuration ?? 300;
|
|
72
|
+
this.log(`setMeshes called. newSetSize=${newSet ? newSet.size : 0}, autoRestorePrev=${autoRestorePrev}`);
|
|
73
|
+
// If the newSet is null and currentSet is null -> nothing
|
|
74
|
+
if (!newSet && !this.currentSet) {
|
|
75
|
+
this.log('setMeshes: both newSet and currentSet are null, nothing to do');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// If both exist and are the same reference, we still must detect content changes.
|
|
79
|
+
const sameReference = this.currentSet === newSet;
|
|
80
|
+
// Prepare prevSet snapshot (we copy current to prev)
|
|
81
|
+
if (this.currentSet) {
|
|
82
|
+
this.prevSet = this.currentSet;
|
|
83
|
+
this.prevStateMap = new Map(this.stateMap);
|
|
84
|
+
this.log(`setMeshes: backed up current->prev prevSetSize=${this.prevSet.size}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
this.prevSet = null;
|
|
88
|
+
this.prevStateMap = new Map();
|
|
89
|
+
}
|
|
90
|
+
// If we used to be exploded and need to restore prevSet, do that first (await)
|
|
91
|
+
if (this.prevSet && autoRestorePrev && this.isExploded) {
|
|
92
|
+
this.log('setMeshes: need to restore prevSet before applying newSet');
|
|
93
|
+
await this.restoreSet(this.prevSet, this.prevStateMap, restoreDuration, { debug: true });
|
|
94
|
+
this.log('setMeshes: prevSet restore done');
|
|
95
|
+
this.prevStateMap.clear();
|
|
96
|
+
this.prevSet = null;
|
|
97
|
+
}
|
|
98
|
+
// Now register newSet: we clear and rebuild stateMap carefully.
|
|
99
|
+
// But we must handle the case where caller reuses same Set object and just mutated elements.
|
|
100
|
+
// We will compute additions and removals.
|
|
101
|
+
const oldSet = this.currentSet;
|
|
102
|
+
this.currentSet = newSet;
|
|
103
|
+
// If newSet is null -> simply clear stateMap
|
|
104
|
+
if (!this.currentSet) {
|
|
105
|
+
this.stateMap.clear();
|
|
106
|
+
this.log('setMeshes: newSet is null -> cleared stateMap');
|
|
107
|
+
this.isExploded = false;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// If we have oldSet (could be same reference) then compute diffs
|
|
111
|
+
if (oldSet) {
|
|
112
|
+
// If same reference but size or content differs -> handle diffs
|
|
113
|
+
const wasSameRef = sameReference;
|
|
114
|
+
let added = [];
|
|
115
|
+
let removed = [];
|
|
116
|
+
// Build maps of membership
|
|
117
|
+
const oldMembers = new Set(Array.from(oldSet));
|
|
118
|
+
const newMembers = new Set(Array.from(this.currentSet));
|
|
119
|
+
// find removals
|
|
120
|
+
oldMembers.forEach((m) => {
|
|
121
|
+
if (!newMembers.has(m))
|
|
122
|
+
removed.push(m);
|
|
123
|
+
});
|
|
124
|
+
// find additions
|
|
125
|
+
newMembers.forEach((m) => {
|
|
126
|
+
if (!oldMembers.has(m))
|
|
127
|
+
added.push(m);
|
|
128
|
+
});
|
|
129
|
+
if (wasSameRef && added.length === 0 && removed.length === 0) {
|
|
130
|
+
// truly identical (no content changes)
|
|
131
|
+
this.log('setMeshes: same reference and identical contents -> nothing to update');
|
|
116
132
|
return;
|
|
117
133
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
let removed = [];
|
|
124
|
-
// Build maps of membership
|
|
125
|
-
const oldMembers = new Set(Array.from(oldSet));
|
|
126
|
-
const newMembers = new Set(Array.from(this.currentSet));
|
|
127
|
-
// find removals
|
|
128
|
-
oldMembers.forEach((m) => {
|
|
129
|
-
if (!newMembers.has(m))
|
|
130
|
-
removed.push(m);
|
|
131
|
-
});
|
|
132
|
-
// find additions
|
|
133
|
-
newMembers.forEach((m) => {
|
|
134
|
-
if (!oldMembers.has(m))
|
|
135
|
-
added.push(m);
|
|
136
|
-
});
|
|
137
|
-
if (wasSameRef && added.length === 0 && removed.length === 0) {
|
|
138
|
-
// truly identical (no content changes)
|
|
139
|
-
this.log('setMeshes: same reference and identical contents -> nothing to update');
|
|
140
|
-
return;
|
|
134
|
+
this.log(`setMeshes: diff detected -> added=${added.length}, removed=${removed.length}`);
|
|
135
|
+
// Remove snapshots for removed meshes
|
|
136
|
+
removed.forEach((m) => {
|
|
137
|
+
if (this.stateMap.has(m)) {
|
|
138
|
+
this.stateMap.delete(m);
|
|
141
139
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
this.stateMap.clear();
|
|
158
|
-
yield this.ensureSnapshotsForSet(this.currentSet);
|
|
159
|
-
this.log(`setMeshes: recorded stateMap entries for newSet size=${this.stateMap.size}`);
|
|
160
|
-
this.isExploded = false;
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
});
|
|
140
|
+
});
|
|
141
|
+
// Ensure snapshots exist for current set members (create for newly added meshes)
|
|
142
|
+
await this.ensureSnapshotsForSet(this.currentSet);
|
|
143
|
+
this.log(`setMeshes: after diff handling, stateMap size=${this.stateMap.size}`);
|
|
144
|
+
this.isExploded = false;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// no oldSet -> brand new registration
|
|
149
|
+
this.stateMap.clear();
|
|
150
|
+
await this.ensureSnapshotsForSet(this.currentSet);
|
|
151
|
+
this.log(`setMeshes: recorded stateMap entries for newSet size=${this.stateMap.size}`);
|
|
152
|
+
this.isExploded = false;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
164
155
|
}
|
|
165
156
|
/**
|
|
166
157
|
* ensureSnapshotsForSet: for each mesh in set, ensure stateMap has an entry.
|
|
167
158
|
* If missing, record current matrixWorld as originalMatrixWorld (best-effort).
|
|
168
159
|
*/
|
|
169
|
-
ensureSnapshotsForSet(set) {
|
|
170
|
-
|
|
171
|
-
|
|
160
|
+
async ensureSnapshotsForSet(set) {
|
|
161
|
+
set.forEach((m) => {
|
|
162
|
+
try {
|
|
163
|
+
m.updateMatrixWorld(true);
|
|
164
|
+
}
|
|
165
|
+
catch { }
|
|
166
|
+
if (!this.stateMap.has(m)) {
|
|
172
167
|
try {
|
|
173
|
-
|
|
168
|
+
this.stateMap.set(m, {
|
|
169
|
+
originalParent: m.parent || null,
|
|
170
|
+
originalMatrixWorld: (m.matrixWorld && m.matrixWorld.clone()) || new THREE.Matrix4().copy(m.matrix),
|
|
171
|
+
});
|
|
172
|
+
// Also store in userData for extra resilience
|
|
173
|
+
m.userData.__originalMatrixWorld = this.stateMap.get(m).originalMatrixWorld.clone();
|
|
174
174
|
}
|
|
175
|
-
catch (
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
this.stateMap.set(m, {
|
|
179
|
-
originalParent: m.parent || null,
|
|
180
|
-
originalMatrixWorld: (m.matrixWorld && m.matrixWorld.clone()) || new THREE.Matrix4().copy(m.matrix),
|
|
181
|
-
});
|
|
182
|
-
// Also store in userData for extra resilience
|
|
183
|
-
m.userData.__originalMatrixWorld = this.stateMap.get(m).originalMatrixWorld.clone();
|
|
184
|
-
}
|
|
185
|
-
catch (e) {
|
|
186
|
-
this.log(`ensureSnapshotsForSet: failed to snapshot mesh ${m.name || m.id}: ${e.message}`);
|
|
187
|
-
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
this.log(`ensureSnapshotsForSet: failed to snapshot mesh ${m.name || m.id}: ${e.message}`);
|
|
188
177
|
}
|
|
189
|
-
}
|
|
178
|
+
}
|
|
190
179
|
});
|
|
191
180
|
}
|
|
192
181
|
/**
|
|
193
182
|
* explode: compute targets first, compute targetBound using targets + mesh radii,
|
|
194
183
|
* animate camera to that targetBound, then animate meshes to targets.
|
|
195
184
|
*/
|
|
196
|
-
explode(opts) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
185
|
+
async explode(opts) {
|
|
186
|
+
if (!this.currentSet || this.currentSet.size === 0) {
|
|
187
|
+
this.log('explode: empty currentSet, nothing to do');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const { spacing = 2, duration = 1000, lift = 0.5, cameraPadding = 1.5, mode = 'spiral', dimOthers = { enabled: true, opacity: 0.25 }, debug = false, } = opts || {};
|
|
191
|
+
this.log(`explode called. setSize=${this.currentSet.size}, mode=${mode}, spacing=${spacing}, duration=${duration}, lift=${lift}, dim=${dimOthers.enabled}`);
|
|
192
|
+
this.cancelAnimations();
|
|
193
|
+
const meshes = Array.from(this.currentSet);
|
|
194
|
+
// ensure snapshots exist for any meshes that may have been added after initial registration
|
|
195
|
+
await this.ensureSnapshotsForSet(this.currentSet);
|
|
196
|
+
// compute center/radius from current meshes (fallback)
|
|
197
|
+
const initial = this.computeBoundingSphereForMeshes(meshes);
|
|
198
|
+
const center = initial.center;
|
|
199
|
+
const baseRadius = Math.max(1, initial.radius);
|
|
200
|
+
this.log(`explode: initial center=${center.toArray().map((n) => n.toFixed(3))}, baseRadius=${baseRadius.toFixed(3)}`);
|
|
201
|
+
// compute targets (pure calculation)
|
|
202
|
+
const targets = this.computeTargetsByMode(meshes, center, baseRadius + spacing, { lift, mode });
|
|
203
|
+
this.log(`explode: computed ${targets.length} target positions`);
|
|
204
|
+
// compute target-based bounding sphere (targets + per-mesh radius)
|
|
205
|
+
const targetBound = this.computeBoundingSphereForPositionsAndMeshes(targets, meshes);
|
|
206
|
+
this.log(`explode: targetBound center=${targetBound.center.toArray().map((n) => n.toFixed(3))}, radius=${targetBound.radius.toFixed(3)}`);
|
|
207
|
+
await this.animateCameraToFit(targetBound.center, targetBound.radius, { duration: Math.min(600, duration), padding: cameraPadding });
|
|
208
|
+
this.log('explode: camera animation to target bound completed');
|
|
209
|
+
// apply dim if needed with context id
|
|
210
|
+
const contextId = dimOthers?.enabled ? this.applyDimToOthers(meshes, dimOthers.opacity ?? 0.25, { debug }) : null;
|
|
211
|
+
if (contextId)
|
|
212
|
+
this.log(`explode: applied dim for context ${contextId}`);
|
|
213
|
+
// capture starts after camera move
|
|
214
|
+
const starts = meshes.map((m) => {
|
|
215
|
+
const v = new THREE.Vector3();
|
|
216
|
+
try {
|
|
217
|
+
m.getWorldPosition(v);
|
|
202
218
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
this.log(`explode: applied dim for context ${contextId}`);
|
|
226
|
-
// capture starts after camera move
|
|
227
|
-
const starts = meshes.map((m) => {
|
|
228
|
-
const v = new THREE.Vector3();
|
|
229
|
-
try {
|
|
230
|
-
m.getWorldPosition(v);
|
|
231
|
-
}
|
|
232
|
-
catch (_a) {
|
|
233
|
-
// fallback to originalMatrixWorld if available
|
|
234
|
-
const st = this.stateMap.get(m);
|
|
235
|
-
if (st)
|
|
236
|
-
v.setFromMatrixPosition(st.originalMatrixWorld);
|
|
237
|
-
}
|
|
238
|
-
return v;
|
|
239
|
-
});
|
|
240
|
-
const startTime = performance.now();
|
|
241
|
-
const total = Math.max(1, duration);
|
|
242
|
-
const tick = (now) => {
|
|
243
|
-
const t = Math.min(1, (now - startTime) / total);
|
|
244
|
-
const eased = easeInOutQuad(t);
|
|
245
|
-
for (let i = 0; i < meshes.length; i++) {
|
|
246
|
-
const m = meshes[i];
|
|
247
|
-
const s = starts[i];
|
|
248
|
-
const tar = targets[i];
|
|
249
|
-
const cur = s.clone().lerp(tar, eased);
|
|
250
|
-
if (m.parent) {
|
|
251
|
-
const local = cur.clone();
|
|
252
|
-
m.parent.worldToLocal(local);
|
|
253
|
-
m.position.copy(local);
|
|
254
|
-
}
|
|
255
|
-
else {
|
|
256
|
-
m.position.copy(cur);
|
|
257
|
-
}
|
|
258
|
-
m.updateMatrix();
|
|
259
|
-
}
|
|
260
|
-
if (this.controls && typeof this.controls.update === 'function')
|
|
261
|
-
this.controls.update();
|
|
262
|
-
if (t < 1) {
|
|
263
|
-
this.animId = requestAnimationFrame(tick);
|
|
219
|
+
catch {
|
|
220
|
+
// fallback to originalMatrixWorld if available
|
|
221
|
+
const st = this.stateMap.get(m);
|
|
222
|
+
if (st)
|
|
223
|
+
v.setFromMatrixPosition(st.originalMatrixWorld);
|
|
224
|
+
}
|
|
225
|
+
return v;
|
|
226
|
+
});
|
|
227
|
+
const startTime = performance.now();
|
|
228
|
+
const total = Math.max(1, duration);
|
|
229
|
+
const tick = (now) => {
|
|
230
|
+
const t = Math.min(1, (now - startTime) / total);
|
|
231
|
+
const eased = easeInOutQuad(t);
|
|
232
|
+
for (let i = 0; i < meshes.length; i++) {
|
|
233
|
+
const m = meshes[i];
|
|
234
|
+
const s = starts[i];
|
|
235
|
+
const tar = targets[i];
|
|
236
|
+
const cur = s.clone().lerp(tar, eased);
|
|
237
|
+
if (m.parent) {
|
|
238
|
+
const local = cur.clone();
|
|
239
|
+
m.parent.worldToLocal(local);
|
|
240
|
+
m.position.copy(local);
|
|
264
241
|
}
|
|
265
242
|
else {
|
|
266
|
-
|
|
267
|
-
this.isExploded = true;
|
|
268
|
-
this.log(`explode: completed. contextId=${contextId !== null && contextId !== void 0 ? contextId : 'none'}`);
|
|
243
|
+
m.position.copy(cur);
|
|
269
244
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
245
|
+
m.updateMatrix();
|
|
246
|
+
}
|
|
247
|
+
if (this.controls && typeof this.controls.update === 'function')
|
|
248
|
+
this.controls.update();
|
|
249
|
+
if (t < 1) {
|
|
250
|
+
this.animId = requestAnimationFrame(tick);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
this.animId = null;
|
|
254
|
+
this.isExploded = true;
|
|
255
|
+
this.log(`explode: completed. contextId=${contextId ?? 'none'}`);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
this.animId = requestAnimationFrame(tick);
|
|
259
|
+
return;
|
|
274
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Restore all exploded meshes to their original transform:
|
|
263
|
+
* - Supports smooth animation
|
|
264
|
+
* - Automatically cancels transparency
|
|
265
|
+
*/
|
|
275
266
|
restore(duration = 400) {
|
|
276
267
|
if (!this.currentSet || this.currentSet.size === 0) {
|
|
277
268
|
this.log('restore: no currentSet to restore');
|
|
@@ -288,7 +279,7 @@ class GroupExploder {
|
|
|
288
279
|
*/
|
|
289
280
|
restoreSet(set, stateMap, duration = 400, opts) {
|
|
290
281
|
if (!set || set.size === 0) {
|
|
291
|
-
if (opts
|
|
282
|
+
if (opts?.debug)
|
|
292
283
|
this.log('restoreSet: empty set, nothing to restore');
|
|
293
284
|
return Promise.resolve();
|
|
294
285
|
}
|
|
@@ -301,12 +292,12 @@ class GroupExploder {
|
|
|
301
292
|
try {
|
|
302
293
|
m.updateMatrixWorld(true);
|
|
303
294
|
}
|
|
304
|
-
catch
|
|
295
|
+
catch { }
|
|
305
296
|
const s = new THREE.Vector3();
|
|
306
297
|
try {
|
|
307
298
|
m.getWorldPosition(s);
|
|
308
299
|
}
|
|
309
|
-
catch
|
|
300
|
+
catch {
|
|
310
301
|
s.set(0, 0, 0);
|
|
311
302
|
}
|
|
312
303
|
starts.push(s);
|
|
@@ -404,7 +395,7 @@ class GroupExploder {
|
|
|
404
395
|
});
|
|
405
396
|
}
|
|
406
397
|
// material dim with context id
|
|
407
|
-
applyDimToOthers(explodingMeshes, opacity = 0.25,
|
|
398
|
+
applyDimToOthers(explodingMeshes, opacity = 0.25, _opts) {
|
|
408
399
|
const contextId = `ctx_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
|
409
400
|
const explodingSet = new Set(explodingMeshes);
|
|
410
401
|
const touched = new Set();
|
|
@@ -415,11 +406,10 @@ class GroupExploder {
|
|
|
415
406
|
if (explodingSet.has(mesh))
|
|
416
407
|
return;
|
|
417
408
|
const applyMat = (mat) => {
|
|
418
|
-
var _a;
|
|
419
409
|
if (!this.materialSnaps.has(mat)) {
|
|
420
410
|
this.materialSnaps.set(mat, {
|
|
421
411
|
transparent: !!mat.transparent,
|
|
422
|
-
opacity:
|
|
412
|
+
opacity: mat.opacity ?? 1,
|
|
423
413
|
depthWrite: mat.depthWrite,
|
|
424
414
|
});
|
|
425
415
|
}
|
|
@@ -446,7 +436,7 @@ class GroupExploder {
|
|
|
446
436
|
return contextId;
|
|
447
437
|
}
|
|
448
438
|
// clean contexts for meshes (restore materials whose contexts are removed)
|
|
449
|
-
cleanContextsForMeshes(
|
|
439
|
+
cleanContextsForMeshes(_meshes) {
|
|
450
440
|
// conservative strategy: for each context we created, delete it and restore materials accordingly
|
|
451
441
|
for (const [contextId, mats] of Array.from(this.contextMaterials.entries())) {
|
|
452
442
|
mats.forEach((mat) => {
|
|
@@ -560,7 +550,7 @@ class GroupExploder {
|
|
|
560
550
|
}
|
|
561
551
|
}
|
|
562
552
|
}
|
|
563
|
-
catch
|
|
553
|
+
catch {
|
|
564
554
|
radius = 0;
|
|
565
555
|
}
|
|
566
556
|
if (!isFinite(radius) || radius < 0 || radius > 1e8)
|
|
@@ -583,10 +573,9 @@ class GroupExploder {
|
|
|
583
573
|
}
|
|
584
574
|
// computeTargetsByMode (unchanged logic but pure function)
|
|
585
575
|
computeTargetsByMode(meshes, center, baseRadius, opts) {
|
|
586
|
-
var _a, _b;
|
|
587
576
|
const n = meshes.length;
|
|
588
|
-
const lift =
|
|
589
|
-
const mode =
|
|
577
|
+
const lift = opts.lift ?? 0.5;
|
|
578
|
+
const mode = opts.mode ?? 'ring';
|
|
590
579
|
const targets = [];
|
|
591
580
|
if (mode === 'ring') {
|
|
592
581
|
for (let i = 0; i < n; i++) {
|
|
@@ -628,99 +617,109 @@ class GroupExploder {
|
|
|
628
617
|
return targets;
|
|
629
618
|
}
|
|
630
619
|
animateCameraToFit(targetCenter, targetRadius, opts) {
|
|
631
|
-
|
|
632
|
-
const
|
|
633
|
-
const padding = (_b = opts === null || opts === void 0 ? void 0 : opts.padding) !== null && _b !== void 0 ? _b : 1.5;
|
|
620
|
+
const duration = opts?.duration ?? 600;
|
|
621
|
+
const padding = opts?.padding ?? 1.5;
|
|
634
622
|
if (!(this.camera instanceof THREE.PerspectiveCamera)) {
|
|
635
623
|
if (this.controls && this.controls.target) {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
624
|
+
// Fallback for non-PerspectiveCamera
|
|
625
|
+
const startTarget = this.controls.target.clone();
|
|
626
|
+
const startPos = this.camera.position.clone();
|
|
627
|
+
const endTarget = targetCenter.clone();
|
|
628
|
+
const dir = startPos.clone().sub(startTarget).normalize();
|
|
629
|
+
const dist = startPos.distanceTo(startTarget);
|
|
630
|
+
const endPos = endTarget.clone().add(dir.multiplyScalar(dist));
|
|
631
|
+
const startTime = performance.now();
|
|
632
|
+
const tick = (now) => {
|
|
633
|
+
const t = Math.min(1, (now - startTime) / duration);
|
|
634
|
+
const k = easeInOutQuad(t);
|
|
635
|
+
if (this.controls && this.controls.target) {
|
|
636
|
+
this.controls.target.lerpVectors(startTarget, endTarget, k);
|
|
637
|
+
}
|
|
638
|
+
this.camera.position.lerpVectors(startPos, endPos, k);
|
|
639
|
+
if (this.controls?.update)
|
|
640
|
+
this.controls.update();
|
|
641
|
+
if (t < 1) {
|
|
642
|
+
this.cameraAnimId = requestAnimationFrame(tick);
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
this.cameraAnimId = null;
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
this.cameraAnimId = requestAnimationFrame(tick);
|
|
639
649
|
}
|
|
640
650
|
return Promise.resolve();
|
|
641
651
|
}
|
|
642
|
-
|
|
643
|
-
const fov = (
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
652
|
+
// PerspectiveCamera logic
|
|
653
|
+
const fov = THREE.MathUtils.degToRad(this.camera.fov);
|
|
654
|
+
const aspect = this.camera.aspect;
|
|
655
|
+
// Calculate distance needed to fit the sphere
|
|
656
|
+
// tan(fov/2) = radius / distance => distance = radius / tan(fov/2)
|
|
657
|
+
// We also consider aspect ratio for horizontal fit
|
|
658
|
+
const distV = targetRadius / Math.sin(fov / 2);
|
|
659
|
+
const distH = targetRadius / Math.sin(Math.min(fov, fov * aspect) / 2); // approximate
|
|
660
|
+
const dist = Math.max(distV, distH) * padding;
|
|
661
|
+
const startPos = this.camera.position.clone();
|
|
662
|
+
const startTarget = this.controls?.target ? this.controls.target.clone() : new THREE.Vector3(); // assumption
|
|
663
|
+
if (!this.controls?.target) {
|
|
664
|
+
this.camera.getWorldDirection(startTarget);
|
|
665
|
+
startTarget.add(startPos);
|
|
666
|
+
}
|
|
667
|
+
// Determine end position: keep current viewing direction relative to center
|
|
668
|
+
const dir = startPos.clone().sub(startTarget).normalize();
|
|
669
|
+
if (dir.lengthSq() < 0.001)
|
|
649
670
|
dir.set(0, 0, 1);
|
|
650
|
-
else
|
|
651
|
-
dir.normalize();
|
|
652
|
-
const newCamPos = targetCenter.clone().add(dir.multiplyScalar(desiredDistance));
|
|
653
|
-
const startPos = cam.position.clone();
|
|
654
|
-
const startTarget = (this.controls && this.controls.target) ? (this.controls.target.clone()) : this.getCameraLookAtPoint();
|
|
655
671
|
const endTarget = targetCenter.clone();
|
|
656
|
-
const
|
|
672
|
+
const endPos = endTarget.clone().add(dir.multiplyScalar(dist));
|
|
657
673
|
return new Promise((resolve) => {
|
|
674
|
+
const startTime = performance.now();
|
|
658
675
|
const tick = (now) => {
|
|
659
|
-
const t = Math.min(1, (now - startTime) /
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
if (this.controls && this.controls.target)
|
|
663
|
-
this.controls.target.lerpVectors(startTarget, endTarget,
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
676
|
+
const t = Math.min(1, (now - startTime) / duration);
|
|
677
|
+
const k = easeInOutQuad(t);
|
|
678
|
+
this.camera.position.lerpVectors(startPos, endPos, k);
|
|
679
|
+
if (this.controls && this.controls.target) {
|
|
680
|
+
this.controls.target.lerpVectors(startTarget, endTarget, k);
|
|
681
|
+
this.controls.update?.();
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
this.camera.lookAt(endTarget); // simple lookAt if no controls
|
|
685
|
+
}
|
|
686
|
+
if (t < 1) {
|
|
668
687
|
this.cameraAnimId = requestAnimationFrame(tick);
|
|
688
|
+
}
|
|
669
689
|
else {
|
|
670
690
|
this.cameraAnimId = null;
|
|
671
|
-
this.log(`animateCameraToFit: done. center=${targetCenter.toArray().map((n) => n.toFixed(2))}, radius=${targetRadius.toFixed(2)}`);
|
|
672
691
|
resolve();
|
|
673
692
|
}
|
|
674
693
|
};
|
|
675
694
|
this.cameraAnimId = requestAnimationFrame(tick);
|
|
676
695
|
});
|
|
677
696
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
return this.camera.position.clone().add(dir.multiplyScalar(10));
|
|
682
|
-
}
|
|
697
|
+
/**
|
|
698
|
+
* Cancel all running animations
|
|
699
|
+
*/
|
|
683
700
|
cancelAnimations() {
|
|
684
|
-
if (this.animId) {
|
|
701
|
+
if (this.animId !== null) {
|
|
685
702
|
cancelAnimationFrame(this.animId);
|
|
686
703
|
this.animId = null;
|
|
687
704
|
}
|
|
688
|
-
if (this.cameraAnimId) {
|
|
705
|
+
if (this.cameraAnimId !== null) {
|
|
689
706
|
cancelAnimationFrame(this.cameraAnimId);
|
|
690
707
|
this.cameraAnimId = null;
|
|
691
708
|
}
|
|
692
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Dispose: remove listener, cancel animation, clear references
|
|
712
|
+
*/
|
|
693
713
|
dispose() {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
for (const [mat, ctxs] of Array.from(this.materialContexts.entries())) {
|
|
704
|
-
const snap = this.materialSnaps.get(mat);
|
|
705
|
-
if (snap) {
|
|
706
|
-
mat.transparent = snap.transparent;
|
|
707
|
-
mat.opacity = snap.opacity;
|
|
708
|
-
if (typeof snap.depthWrite !== 'undefined')
|
|
709
|
-
mat.depthWrite = snap.depthWrite;
|
|
710
|
-
mat.needsUpdate = true;
|
|
711
|
-
}
|
|
712
|
-
this.materialContexts.delete(mat);
|
|
713
|
-
this.materialSnaps.delete(mat);
|
|
714
|
-
}
|
|
715
|
-
this.contextMaterials.clear();
|
|
716
|
-
this.stateMap.clear();
|
|
717
|
-
this.prevStateMap.clear();
|
|
718
|
-
this.currentSet = null;
|
|
719
|
-
this.prevSet = null;
|
|
720
|
-
this.isInitialized = false;
|
|
721
|
-
this.isExploded = false;
|
|
722
|
-
this.log('dispose: cleaned up');
|
|
723
|
-
});
|
|
714
|
+
this.cancelAnimations();
|
|
715
|
+
this.currentSet = null;
|
|
716
|
+
this.prevSet = null;
|
|
717
|
+
this.stateMap.clear();
|
|
718
|
+
this.prevStateMap.clear();
|
|
719
|
+
this.materialContexts.clear();
|
|
720
|
+
this.materialSnaps.clear();
|
|
721
|
+
this.contextMaterials.clear();
|
|
722
|
+
this.log('dispose() called, resources cleaned up');
|
|
724
723
|
}
|
|
725
724
|
}
|
|
726
725
|
|