@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +134 -97
  3. package/dist/camera/index.d.ts +59 -36
  4. package/dist/camera/index.js +83 -67
  5. package/dist/camera/index.js.map +1 -1
  6. package/dist/camera/index.mjs +83 -67
  7. package/dist/camera/index.mjs.map +1 -1
  8. package/dist/core/index.d.ts +81 -28
  9. package/dist/core/index.js +194 -104
  10. package/dist/core/index.js.map +1 -1
  11. package/dist/core/index.mjs +194 -105
  12. package/dist/core/index.mjs.map +1 -1
  13. package/dist/effect/index.d.ts +47 -134
  14. package/dist/effect/index.js +287 -288
  15. package/dist/effect/index.js.map +1 -1
  16. package/dist/effect/index.mjs +287 -288
  17. package/dist/effect/index.mjs.map +1 -1
  18. package/dist/index.d.ts +432 -349
  19. package/dist/index.js +1399 -1228
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs +1395 -1229
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/interaction/index.d.ts +85 -52
  24. package/dist/interaction/index.js +168 -142
  25. package/dist/interaction/index.js.map +1 -1
  26. package/dist/interaction/index.mjs +168 -142
  27. package/dist/interaction/index.mjs.map +1 -1
  28. package/dist/loader/index.d.ts +106 -58
  29. package/dist/loader/index.js +492 -454
  30. package/dist/loader/index.js.map +1 -1
  31. package/dist/loader/index.mjs +491 -455
  32. package/dist/loader/index.mjs.map +1 -1
  33. package/dist/setup/index.d.ts +26 -24
  34. package/dist/setup/index.js +125 -163
  35. package/dist/setup/index.js.map +1 -1
  36. package/dist/setup/index.mjs +124 -164
  37. package/dist/setup/index.mjs.map +1 -1
  38. package/dist/ui/index.d.ts +18 -7
  39. package/dist/ui/index.js +45 -37
  40. package/dist/ui/index.js.map +1 -1
  41. package/dist/ui/index.mjs +45 -37
  42. package/dist/ui/index.mjs.map +1 -1
  43. package/package.json +50 -22
@@ -1,41 +1,33 @@
1
1
  import * as THREE from 'three';
2
2
 
3
- /******************************************************************************
4
- Copyright (c) Microsoft Corporation.
5
-
6
- Permission to use, copy, modify, and/or distribute this software for any
7
- purpose with or without fee is hereby granted.
8
-
9
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
- PERFORMANCE OF THIS SOFTWARE.
16
- ***************************************************************************** */
17
- /* global Reflect, Promise, SuppressedError, Symbol, Iterator */
18
-
19
-
20
- function __awaiter(thisArg, _arguments, P, generator) {
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
- * setMeshes(newSet):
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
- return __awaiter(this, void 0, void 0, function* () {
77
- var _a, _b;
78
- const autoRestorePrev = (_a = options === null || options === void 0 ? void 0 : options.autoRestorePrev) !== null && _a !== void 0 ? _a : true;
79
- const restoreDuration = (_b = options === null || options === void 0 ? void 0 : options.restoreDuration) !== null && _b !== void 0 ? _b : 300;
80
- this.log(`setMeshes called. newSetSize=${newSet ? newSet.size : 0}, autoRestorePrev=${autoRestorePrev}`);
81
- // If the newSet is null and currentSet is null -> nothing
82
- if (!newSet && !this.currentSet) {
83
- this.log('setMeshes: both newSet and currentSet are null, nothing to do');
84
- return;
85
- }
86
- // If both exist and are the same reference, we still must detect content changes.
87
- const sameReference = this.currentSet === newSet;
88
- // Prepare prevSet snapshot (we copy current to prev)
89
- if (this.currentSet) {
90
- this.prevSet = this.currentSet;
91
- this.prevStateMap = new Map(this.stateMap);
92
- this.log(`setMeshes: backed up current->prev prevSetSize=${this.prevSet.size}`);
93
- }
94
- else {
95
- this.prevSet = null;
96
- this.prevStateMap = new Map();
97
- }
98
- // If we used to be exploded and need to restore prevSet, do that first (await)
99
- if (this.prevSet && autoRestorePrev && this.isExploded) {
100
- this.log('setMeshes: need to restore prevSet before applying newSet');
101
- yield this.restoreSet(this.prevSet, this.prevStateMap, restoreDuration, { debug: true });
102
- this.log('setMeshes: prevSet restore done');
103
- this.prevStateMap.clear();
104
- this.prevSet = null;
105
- }
106
- // Now register newSet: we clear and rebuild stateMap carefully.
107
- // But we must handle the case where caller reuses same Set object and just mutated elements.
108
- // We will compute additions and removals.
109
- const oldSet = this.currentSet;
110
- this.currentSet = newSet;
111
- // If newSet is null -> simply clear stateMap
112
- if (!this.currentSet) {
113
- this.stateMap.clear();
114
- this.log('setMeshes: newSet is null -> cleared stateMap');
115
- this.isExploded = false;
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
- // If we have oldSet (could be same reference) then compute diffs
119
- if (oldSet) {
120
- // If same reference but size or content differs -> handle diffs
121
- const wasSameRef = sameReference;
122
- let added = [];
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
- this.log(`setMeshes: diff detected -> added=${added.length}, removed=${removed.length}`);
143
- // Remove snapshots for removed meshes
144
- removed.forEach((m) => {
145
- if (this.stateMap.has(m)) {
146
- this.stateMap.delete(m);
147
- }
148
- });
149
- // Ensure snapshots exist for current set members (create for newly added meshes)
150
- yield this.ensureSnapshotsForSet(this.currentSet);
151
- this.log(`setMeshes: after diff handling, stateMap size=${this.stateMap.size}`);
152
- this.isExploded = false;
153
- return;
154
- }
155
- else {
156
- // no oldSet -> brand new registration
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
- return __awaiter(this, void 0, void 0, function* () {
171
- set.forEach((m) => {
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
- m.updateMatrixWorld(true);
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 (_a) { }
176
- if (!this.stateMap.has(m)) {
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
- return __awaiter(this, void 0, void 0, function* () {
198
- var _a;
199
- if (!this.currentSet || this.currentSet.size === 0) {
200
- this.log('explode: empty currentSet, nothing to do');
201
- return;
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
- const { spacing = 2, duration = 1000, lift = 0.5, cameraPadding = 1.5, mode = 'spiral', dimOthers = { enabled: true, opacity: 0.25 }, debug = false, } = opts || {};
204
- this.log(`explode called. setSize=${this.currentSet.size}, mode=${mode}, spacing=${spacing}, duration=${duration}, lift=${lift}, dim=${dimOthers.enabled}`);
205
- this.cancelAnimations();
206
- const meshes = Array.from(this.currentSet);
207
- // ensure snapshots exist for any meshes that may have been added after initial registration
208
- yield this.ensureSnapshotsForSet(this.currentSet);
209
- // compute center/radius from current meshes (fallback)
210
- const initial = this.computeBoundingSphereForMeshes(meshes);
211
- const center = initial.center;
212
- const baseRadius = Math.max(1, initial.radius);
213
- this.log(`explode: initial center=${center.toArray().map((n) => n.toFixed(3))}, baseRadius=${baseRadius.toFixed(3)}`);
214
- // compute targets (pure calculation)
215
- const targets = this.computeTargetsByMode(meshes, center, baseRadius + spacing, { lift, mode });
216
- this.log(`explode: computed ${targets.length} target positions`);
217
- // compute target-based bounding sphere (targets + per-mesh radius)
218
- const targetBound = this.computeBoundingSphereForPositionsAndMeshes(targets, meshes);
219
- this.log(`explode: targetBound center=${targetBound.center.toArray().map((n) => n.toFixed(3))}, radius=${targetBound.radius.toFixed(3)}`);
220
- yield this.animateCameraToFit(targetBound.center, targetBound.radius, { duration: Math.min(600, duration), padding: cameraPadding });
221
- this.log('explode: camera animation to target bound completed');
222
- // apply dim if needed with context id
223
- const contextId = (dimOthers === null || dimOthers === void 0 ? void 0 : dimOthers.enabled) ? this.applyDimToOthers(meshes, (_a = dimOthers.opacity) !== null && _a !== void 0 ? _a : 0.25, { debug }) : null;
224
- if (contextId)
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
- this.animId = null;
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
- this.animId = requestAnimationFrame(tick);
272
- return;
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 === null || opts === void 0 ? void 0 : opts.debug)
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 (_a) { }
295
+ catch { }
305
296
  const s = new THREE.Vector3();
306
297
  try {
307
298
  m.getWorldPosition(s);
308
299
  }
309
- catch (_b) {
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, opts) {
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: (_a = mat.opacity) !== null && _a !== void 0 ? _a : 1,
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(meshes) {
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 (_a) {
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 = (_a = opts.lift) !== null && _a !== void 0 ? _a : 0.5;
589
- const mode = (_b = opts.mode) !== null && _b !== void 0 ? _b : 'ring';
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
- var _a, _b, _c;
632
- const duration = (_a = opts === null || opts === void 0 ? void 0 : opts.duration) !== null && _a !== void 0 ? _a : 600;
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
- this.controls.target.copy(targetCenter);
637
- if (typeof this.controls.update === 'function')
638
- this.controls.update();
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
- const cam = this.camera;
643
- const fov = (cam.fov * Math.PI) / 180;
644
- const safeRadius = isFinite(targetRadius) && targetRadius > 0 ? targetRadius : 1;
645
- const desiredDistance = Math.min(1e6, (safeRadius * ((_c = opts === null || opts === void 0 ? void 0 : opts.padding) !== null && _c !== void 0 ? _c : padding)) / Math.sin(fov / 2));
646
- const camPos = cam.position.clone();
647
- const dir = camPos.clone().sub(targetCenter);
648
- if (dir.length() === 0)
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 startTime = performance.now();
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) / Math.max(1, duration));
660
- const eased = easeInOutQuad(t);
661
- cam.position.lerpVectors(startPos, newCamPos, eased);
662
- if (this.controls && this.controls.target)
663
- this.controls.target.lerpVectors(startTarget, endTarget, eased);
664
- cam.updateProjectionMatrix();
665
- if (this.controls && typeof this.controls.update === 'function')
666
- this.controls.update();
667
- if (t < 1)
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
- getCameraLookAtPoint() {
679
- const dir = new THREE.Vector3();
680
- this.camera.getWorldDirection(dir);
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
- return __awaiter(this, arguments, void 0, function* (restoreBefore = true) {
695
- this.cancelAnimations();
696
- if (restoreBefore && this.isExploded) {
697
- try {
698
- yield this.restore(200);
699
- }
700
- catch (_a) { }
701
- }
702
- // force restore of materials
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