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