@joshtol/emotive-engine 3.2.2 → 3.2.3
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/dist/emotive-mascot-3d.js +1 -1
- package/dist/emotive-mascot-3d.js.map +1 -1
- package/dist/emotive-mascot-3d.umd.js +1 -1
- package/dist/emotive-mascot-3d.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/3d/Core3DManager.js +111 -308
- package/src/3d/ThreeRenderer.js +91 -6
- package/src/3d/effects/CrystalSoul.js +10 -16
- package/src/3d/effects/SolarEclipse.js +6 -8
- package/src/3d/geometries/Moon.js +3 -3
- package/src/3d/index.js +24 -8
- package/src/3d/managers/AnimationManager.js +269 -0
- package/src/3d/managers/BehaviorController.js +248 -0
- package/src/3d/managers/BreathingPhaseManager.js +163 -0
- package/src/3d/managers/CameraPresetManager.js +182 -0
- package/src/3d/managers/EffectManager.js +385 -0
- package/src/3d/utils/MaterialFactory.js +6 -5
- package/types/index.d.ts +207 -11
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BehaviorController - Manages rotation, righting, and facing behaviors
|
|
3
|
+
*
|
|
4
|
+
* Centralizes behavior management for 3D mascot:
|
|
5
|
+
* - RotationBehavior: Emotion-driven rotation with wobble
|
|
6
|
+
* - RightingBehavior: Self-stabilization (punching bag effect)
|
|
7
|
+
* - FacingBehavior: Tidal lock for moon geometry
|
|
8
|
+
*
|
|
9
|
+
* Extracted from Core3DManager to improve separation of concerns.
|
|
10
|
+
*
|
|
11
|
+
* @module 3d/managers/BehaviorController
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import RotationBehavior from '../behaviors/RotationBehavior.js';
|
|
15
|
+
import RightingBehavior from '../behaviors/RightingBehavior.js';
|
|
16
|
+
import FacingBehavior from '../behaviors/FacingBehavior.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default righting behavior configuration
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_RIGHTING_CONFIG = {
|
|
22
|
+
strength: 5.0, // Strong righting without overdoing it
|
|
23
|
+
damping: 0.85, // Critically damped for smooth return
|
|
24
|
+
centerOfMass: [0, -0.3, 0], // Bottom-heavy
|
|
25
|
+
axes: { pitch: true, roll: true, yaw: false }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class BehaviorController {
|
|
29
|
+
/**
|
|
30
|
+
* Create behavior controller
|
|
31
|
+
* @param {Object} options - Configuration options
|
|
32
|
+
* @param {boolean} options.rotationDisabled - Whether rotation is disabled
|
|
33
|
+
* @param {boolean} options.wobbleEnabled - Whether wobble effects are enabled
|
|
34
|
+
*/
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.rotationBehavior = null;
|
|
37
|
+
this.rightingBehavior = null;
|
|
38
|
+
this.facingBehavior = null;
|
|
39
|
+
|
|
40
|
+
// State flags
|
|
41
|
+
this.rotationDisabled = options.rotationDisabled || false;
|
|
42
|
+
this.wobbleEnabled = options.wobbleEnabled !== false; // Default: enabled
|
|
43
|
+
|
|
44
|
+
// Initialize righting behavior (always active)
|
|
45
|
+
this.rightingBehavior = new RightingBehavior(DEFAULT_RIGHTING_CONFIG);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Configure rotation behavior based on emotion and geometry
|
|
50
|
+
* @param {Object} options - Configuration options
|
|
51
|
+
* @param {string} options.geometryType - Current geometry type
|
|
52
|
+
* @param {Object} options.emotionData - Emotion data with 3d rotation config
|
|
53
|
+
* @param {Object} options.facingConfig - Facing behavior config (for moon)
|
|
54
|
+
*/
|
|
55
|
+
configureForEmotion(options = {}) {
|
|
56
|
+
const { geometryType, emotionData, facingConfig } = options;
|
|
57
|
+
|
|
58
|
+
// Moon is tidally locked - no rotation
|
|
59
|
+
if (geometryType === 'moon') {
|
|
60
|
+
this._disableRotation();
|
|
61
|
+
this._initFacingBehavior(facingConfig);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Dispose facing behavior if not moon
|
|
66
|
+
this._disposeFacingBehavior();
|
|
67
|
+
|
|
68
|
+
// Skip if rotation is disabled by user
|
|
69
|
+
if (this.rotationDisabled) {
|
|
70
|
+
this._disableRotation();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Configure rotation based on emotion
|
|
75
|
+
if (emotionData?.['3d']?.rotation) {
|
|
76
|
+
this._configureRotationFromEmotion(emotionData['3d'].rotation);
|
|
77
|
+
} else {
|
|
78
|
+
// Default rotation if no emotion-specific config
|
|
79
|
+
this._ensureDefaultRotation();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Reset righting behavior on emotion change
|
|
83
|
+
if (this.rightingBehavior) {
|
|
84
|
+
this.rightingBehavior.reset();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Apply undertone modifiers to behaviors
|
|
90
|
+
* @param {Object} undertone3d - 3D undertone modifiers
|
|
91
|
+
*/
|
|
92
|
+
applyUndertone(undertone3d) {
|
|
93
|
+
if (!undertone3d) return;
|
|
94
|
+
|
|
95
|
+
if (undertone3d.rotation && this.rotationBehavior) {
|
|
96
|
+
this.rotationBehavior.applyUndertoneMultipliers(undertone3d.rotation);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (undertone3d.righting && this.rightingBehavior) {
|
|
100
|
+
this.rightingBehavior.applyUndertoneMultipliers(undertone3d.righting);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Update all behaviors for current frame
|
|
106
|
+
* @param {number} deltaTime - Time since last frame
|
|
107
|
+
* @param {THREE.Euler} baseEuler - Base euler rotation to modify
|
|
108
|
+
*/
|
|
109
|
+
update(deltaTime, baseEuler) {
|
|
110
|
+
// Update rotation behavior
|
|
111
|
+
if (this.rotationBehavior) {
|
|
112
|
+
this.rotationBehavior.update(deltaTime, baseEuler);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Update righting behavior (self-stabilization)
|
|
116
|
+
if (this.rightingBehavior) {
|
|
117
|
+
this.rightingBehavior.update(deltaTime, baseEuler);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Update facing behavior (tidal lock)
|
|
121
|
+
if (this.facingBehavior) {
|
|
122
|
+
this.facingBehavior.update(deltaTime, baseEuler);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Set wobble enabled state
|
|
128
|
+
* @param {boolean} enabled - Whether wobble is enabled
|
|
129
|
+
*/
|
|
130
|
+
setWobbleEnabled(enabled) {
|
|
131
|
+
this.wobbleEnabled = enabled;
|
|
132
|
+
if (this.rotationBehavior) {
|
|
133
|
+
this.rotationBehavior.setWobbleEnabled(enabled);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// When disabling wobble, reset to upright position
|
|
137
|
+
if (!enabled && this.rightingBehavior) {
|
|
138
|
+
this.rightingBehavior.reset();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Reset righting behavior
|
|
144
|
+
*/
|
|
145
|
+
resetRighting() {
|
|
146
|
+
if (this.rightingBehavior) {
|
|
147
|
+
this.rightingBehavior.reset();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get angular velocity from rotation behavior
|
|
153
|
+
* @returns {Array} [x, y, z] angular velocity or [0, 0, 0]
|
|
154
|
+
*/
|
|
155
|
+
getAngularVelocity() {
|
|
156
|
+
return this.rotationBehavior ? this.rotationBehavior.axes : [0, 0, 0];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if rotation behavior is active
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
hasRotationBehavior() {
|
|
164
|
+
return !!this.rotationBehavior;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Configure rotation behavior from emotion data
|
|
169
|
+
* @private
|
|
170
|
+
*/
|
|
171
|
+
_configureRotationFromEmotion(rotationConfig) {
|
|
172
|
+
if (this.rotationBehavior) {
|
|
173
|
+
this.rotationBehavior.updateConfig(rotationConfig);
|
|
174
|
+
} else {
|
|
175
|
+
this.rotationBehavior = new RotationBehavior(rotationConfig);
|
|
176
|
+
}
|
|
177
|
+
this.rotationBehavior.setWobbleEnabled(this.wobbleEnabled);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Ensure default rotation behavior exists
|
|
182
|
+
* @private
|
|
183
|
+
*/
|
|
184
|
+
_ensureDefaultRotation() {
|
|
185
|
+
if (!this.rotationBehavior) {
|
|
186
|
+
this.rotationBehavior = new RotationBehavior({
|
|
187
|
+
axes: [0, 0.3, 0] // Default gentle Y rotation
|
|
188
|
+
});
|
|
189
|
+
this.rotationBehavior.setWobbleEnabled(this.wobbleEnabled);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Disable rotation behavior
|
|
195
|
+
* @private
|
|
196
|
+
*/
|
|
197
|
+
_disableRotation() {
|
|
198
|
+
this.rotationBehavior = null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Initialize facing behavior for tidal lock
|
|
203
|
+
* @private
|
|
204
|
+
*/
|
|
205
|
+
_initFacingBehavior(facingConfig) {
|
|
206
|
+
if (!this.facingBehavior && facingConfig?.enabled) {
|
|
207
|
+
this.facingBehavior = new FacingBehavior({
|
|
208
|
+
strength: facingConfig.strength,
|
|
209
|
+
damping: facingConfig.damping,
|
|
210
|
+
faceAxis: facingConfig.faceAxis,
|
|
211
|
+
targetAxis: facingConfig.targetAxis
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Dispose facing behavior
|
|
218
|
+
* @private
|
|
219
|
+
*/
|
|
220
|
+
_disposeFacingBehavior() {
|
|
221
|
+
if (this.facingBehavior) {
|
|
222
|
+
this.facingBehavior.dispose();
|
|
223
|
+
this.facingBehavior = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Dispose all behaviors and clean up
|
|
229
|
+
*/
|
|
230
|
+
dispose() {
|
|
231
|
+
if (this.rotationBehavior) {
|
|
232
|
+
this.rotationBehavior.destroy?.();
|
|
233
|
+
this.rotationBehavior = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (this.rightingBehavior) {
|
|
237
|
+
this.rightingBehavior.destroy?.();
|
|
238
|
+
this.rightingBehavior = null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (this.facingBehavior) {
|
|
242
|
+
this.facingBehavior.dispose();
|
|
243
|
+
this.facingBehavior = null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export default BehaviorController;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BreathingPhaseManager - Imperative breathing phase animation
|
|
3
|
+
*
|
|
4
|
+
* Provides explicit control over breathing animations for meditation:
|
|
5
|
+
* - breathePhase('inhale', 4) animates scale up over 4 seconds
|
|
6
|
+
* - breathePhase('exhale', 4) animates scale down over 4 seconds
|
|
7
|
+
* - breathePhase('hold', 4) maintains current scale
|
|
8
|
+
*
|
|
9
|
+
* Extracted from Core3DManager to improve separation of concerns.
|
|
10
|
+
*
|
|
11
|
+
* @module 3d/managers/BreathingPhaseManager
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Scale targets for different phases
|
|
16
|
+
*/
|
|
17
|
+
const PHASE_SCALES = {
|
|
18
|
+
inhale: 1.3, // Max inhale size
|
|
19
|
+
exhale: 0.85, // Min exhale size
|
|
20
|
+
neutral: 1.0 // Default/reset scale
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Duration limits in seconds
|
|
25
|
+
*/
|
|
26
|
+
const MIN_DURATION = 0.5;
|
|
27
|
+
const MAX_DURATION = 30;
|
|
28
|
+
|
|
29
|
+
export class BreathingPhaseManager {
|
|
30
|
+
constructor() {
|
|
31
|
+
// Current phase state
|
|
32
|
+
this._phase = null; // 'inhale' | 'hold' | 'exhale' | null
|
|
33
|
+
this._startTime = 0;
|
|
34
|
+
this._duration = 0; // Duration in ms
|
|
35
|
+
this._startScale = 1.0;
|
|
36
|
+
this._targetScale = 1.0;
|
|
37
|
+
this._currentScale = 1.0; // Current animated scale
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Start a breathing phase animation
|
|
42
|
+
* @param {string} phase - 'inhale' | 'exhale' | 'hold'
|
|
43
|
+
* @param {number} durationSec - Duration in seconds for the animation
|
|
44
|
+
*/
|
|
45
|
+
startPhase(phase, durationSec) {
|
|
46
|
+
// Clamp duration to reasonable values
|
|
47
|
+
const duration = Math.max(MIN_DURATION, Math.min(MAX_DURATION, durationSec));
|
|
48
|
+
|
|
49
|
+
// Store current scale as starting point
|
|
50
|
+
this._startScale = this._currentScale;
|
|
51
|
+
this._startTime = performance.now();
|
|
52
|
+
this._duration = duration * 1000; // Convert to ms
|
|
53
|
+
this._phase = phase;
|
|
54
|
+
|
|
55
|
+
// Set target scale based on phase
|
|
56
|
+
switch (phase) {
|
|
57
|
+
case 'inhale':
|
|
58
|
+
this._targetScale = PHASE_SCALES.inhale;
|
|
59
|
+
break;
|
|
60
|
+
case 'exhale':
|
|
61
|
+
this._targetScale = PHASE_SCALES.exhale;
|
|
62
|
+
break;
|
|
63
|
+
case 'hold':
|
|
64
|
+
default:
|
|
65
|
+
// Hold at current scale - no animation needed
|
|
66
|
+
this._targetScale = this._startScale;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stop any active breathing phase animation and reset to neutral scale
|
|
73
|
+
*/
|
|
74
|
+
stop() {
|
|
75
|
+
this._phase = null;
|
|
76
|
+
this._currentScale = PHASE_SCALES.neutral;
|
|
77
|
+
this._startScale = PHASE_SCALES.neutral;
|
|
78
|
+
this._targetScale = PHASE_SCALES.neutral;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if a breathing phase is currently active
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
isActive() {
|
|
86
|
+
return this._phase !== null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get current phase name
|
|
91
|
+
* @returns {string|null}
|
|
92
|
+
*/
|
|
93
|
+
getPhase() {
|
|
94
|
+
return this._phase;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Update breathing phase animation
|
|
99
|
+
* @param {number} _deltaTime - Time since last frame in ms (unused, we use elapsed time)
|
|
100
|
+
* @returns {number} Current breathing phase scale multiplier (1.0 if inactive)
|
|
101
|
+
*/
|
|
102
|
+
update(_deltaTime) {
|
|
103
|
+
// If no active phase, return current scale
|
|
104
|
+
if (!this._phase) {
|
|
105
|
+
return this._currentScale;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const now = performance.now();
|
|
109
|
+
const elapsed = now - this._startTime;
|
|
110
|
+
|
|
111
|
+
// Calculate progress (0 to 1)
|
|
112
|
+
const progress = Math.min(1.0, elapsed / this._duration);
|
|
113
|
+
|
|
114
|
+
// Use sine easing for natural breathing rhythm
|
|
115
|
+
// sin(0 to π/2) maps 0→1 smoothly, reaches target exactly at end
|
|
116
|
+
// This feels more like natural breathing than cubic easing
|
|
117
|
+
const eased = Math.sin(progress * Math.PI / 2);
|
|
118
|
+
|
|
119
|
+
// Interpolate between start and target scale
|
|
120
|
+
this._currentScale = this._startScale +
|
|
121
|
+
(this._targetScale - this._startScale) * eased;
|
|
122
|
+
|
|
123
|
+
// Clear phase when complete
|
|
124
|
+
if (progress >= 1.0) {
|
|
125
|
+
this._currentScale = this._targetScale;
|
|
126
|
+
this._phase = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return this._currentScale;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get current scale multiplier
|
|
134
|
+
* @returns {number}
|
|
135
|
+
*/
|
|
136
|
+
getScaleMultiplier() {
|
|
137
|
+
return this._currentScale;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get current animation state for debugging
|
|
142
|
+
* @returns {Object}
|
|
143
|
+
*/
|
|
144
|
+
getState() {
|
|
145
|
+
return {
|
|
146
|
+
phase: this._phase,
|
|
147
|
+
startScale: this._startScale,
|
|
148
|
+
targetScale: this._targetScale,
|
|
149
|
+
currentScale: this._currentScale,
|
|
150
|
+
duration: this._duration,
|
|
151
|
+
isActive: this._phase !== null
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Dispose manager (nothing to clean up, but follows pattern)
|
|
157
|
+
*/
|
|
158
|
+
dispose() {
|
|
159
|
+
this._phase = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default BreathingPhaseManager;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CameraPresetManager - Manages camera preset views and transitions
|
|
3
|
+
*
|
|
4
|
+
* Provides smooth camera transitions to predefined viewpoints:
|
|
5
|
+
* - front, side, top, angle, back, bottom
|
|
6
|
+
*
|
|
7
|
+
* Extracted from ThreeRenderer to improve separation of concerns.
|
|
8
|
+
*
|
|
9
|
+
* @module 3d/managers/CameraPresetManager
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as THREE from 'three';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Available camera preset positions
|
|
16
|
+
* @param {number} d - Camera distance from origin
|
|
17
|
+
*/
|
|
18
|
+
const getPresets = d => ({
|
|
19
|
+
front: { x: 0, y: 0, z: d },
|
|
20
|
+
side: { x: d, y: 0, z: 0 },
|
|
21
|
+
top: { x: 0, y: d, z: 0 },
|
|
22
|
+
angle: { x: d * 0.67, y: d * 0.5, z: d * 0.67 },
|
|
23
|
+
back: { x: 0, y: 0, z: -d },
|
|
24
|
+
bottom: { x: 0, y: -d, z: 0 }
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export class CameraPresetManager {
|
|
28
|
+
/**
|
|
29
|
+
* Create camera preset manager
|
|
30
|
+
* @param {THREE.Camera} camera - The camera to control
|
|
31
|
+
* @param {OrbitControls} controls - The orbit controls
|
|
32
|
+
* @param {number} cameraDistance - Default distance from origin
|
|
33
|
+
*/
|
|
34
|
+
constructor(camera, controls, cameraDistance = 3) {
|
|
35
|
+
this.camera = camera;
|
|
36
|
+
this.controls = controls;
|
|
37
|
+
this.cameraDistance = cameraDistance;
|
|
38
|
+
this.animationId = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get list of available presets
|
|
43
|
+
* @returns {string[]}
|
|
44
|
+
*/
|
|
45
|
+
getAvailablePresets() {
|
|
46
|
+
return Object.keys(getPresets(this.cameraDistance));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Set camera to a preset view with smooth transition
|
|
51
|
+
* @param {string} preset - Preset name ('front', 'side', 'top', 'angle', 'back', 'bottom')
|
|
52
|
+
* @param {number} duration - Transition duration in ms (default 1000)
|
|
53
|
+
* @param {boolean} preserveTarget - If true, keep the current controls.target (default false)
|
|
54
|
+
*/
|
|
55
|
+
setPreset(preset, duration = 1000, preserveTarget = false) {
|
|
56
|
+
if (!this.controls) return;
|
|
57
|
+
|
|
58
|
+
const presets = getPresets(this.cameraDistance);
|
|
59
|
+
const targetPos = presets[preset];
|
|
60
|
+
|
|
61
|
+
if (!targetPos) {
|
|
62
|
+
console.warn(`Unknown camera preset: ${preset}`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Cancel any ongoing animation
|
|
67
|
+
this.cancelAnimation();
|
|
68
|
+
|
|
69
|
+
// Save current target if we need to preserve it
|
|
70
|
+
const savedTarget = preserveTarget ? this.controls.target.clone() : null;
|
|
71
|
+
|
|
72
|
+
// If instant (duration = 0), set position directly
|
|
73
|
+
if (duration === 0) {
|
|
74
|
+
this._setInstant(targetPos, savedTarget);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Animated transition
|
|
79
|
+
this._animateTo(targetPos, duration, savedTarget, preserveTarget);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Set camera position instantly
|
|
84
|
+
* @private
|
|
85
|
+
*/
|
|
86
|
+
_setInstant(targetPos, savedTarget) {
|
|
87
|
+
// Fully reset OrbitControls to initial state
|
|
88
|
+
this.controls.reset();
|
|
89
|
+
// Then set to target position
|
|
90
|
+
this.camera.position.set(targetPos.x, targetPos.y, targetPos.z);
|
|
91
|
+
// Preserve or reset the controls target
|
|
92
|
+
if (savedTarget) {
|
|
93
|
+
this.controls.target.copy(savedTarget);
|
|
94
|
+
this.camera.lookAt(savedTarget);
|
|
95
|
+
} else {
|
|
96
|
+
this.controls.target.set(0, 0, 0);
|
|
97
|
+
this.camera.lookAt(0, 0, 0);
|
|
98
|
+
}
|
|
99
|
+
this.controls.update();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Animate camera to target position
|
|
104
|
+
* @private
|
|
105
|
+
*/
|
|
106
|
+
_animateTo(targetPos, duration, savedTarget, preserveTarget) {
|
|
107
|
+
// Reset OrbitControls target to center (origin) for animated presets
|
|
108
|
+
if (!preserveTarget) {
|
|
109
|
+
this.controls.target.set(0, 0, 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Smoothly animate camera to target position
|
|
113
|
+
const startPos = this.camera.position.clone();
|
|
114
|
+
const endPos = new THREE.Vector3(targetPos.x, targetPos.y, targetPos.z);
|
|
115
|
+
const startTime = performance.now();
|
|
116
|
+
|
|
117
|
+
const animate = currentTime => {
|
|
118
|
+
const elapsed = currentTime - startTime;
|
|
119
|
+
const progress = Math.min(elapsed / duration, 1.0);
|
|
120
|
+
|
|
121
|
+
// Ease out cubic for smooth deceleration
|
|
122
|
+
const eased = 1 - Math.pow(1 - progress, 3);
|
|
123
|
+
|
|
124
|
+
this.camera.position.lerpVectors(startPos, endPos, eased);
|
|
125
|
+
this.camera.lookAt(0, 0, 0);
|
|
126
|
+
this.controls.update();
|
|
127
|
+
|
|
128
|
+
if (progress < 1.0) {
|
|
129
|
+
this.animationId = requestAnimationFrame(animate);
|
|
130
|
+
} else {
|
|
131
|
+
this.animationId = null;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
this.animationId = requestAnimationFrame(animate);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if animation is in progress
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
isAnimating() {
|
|
143
|
+
return this.animationId !== null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Cancel any ongoing camera animation
|
|
148
|
+
*/
|
|
149
|
+
cancelAnimation() {
|
|
150
|
+
if (this.animationId !== null) {
|
|
151
|
+
cancelAnimationFrame(this.animationId);
|
|
152
|
+
this.animationId = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reset camera to default front view
|
|
158
|
+
* @param {number} duration - Transition duration in ms
|
|
159
|
+
*/
|
|
160
|
+
reset(duration = 1000) {
|
|
161
|
+
this.setPreset('front', duration);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Update camera distance (affects preset positions)
|
|
166
|
+
* @param {number} distance - New camera distance
|
|
167
|
+
*/
|
|
168
|
+
setCameraDistance(distance) {
|
|
169
|
+
this.cameraDistance = distance;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Dispose manager and clean up
|
|
174
|
+
*/
|
|
175
|
+
dispose() {
|
|
176
|
+
this.cancelAnimation();
|
|
177
|
+
this.camera = null;
|
|
178
|
+
this.controls = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export default CameraPresetManager;
|