/** * Animation utilities for AR objects * Provides smooth animation functions for manipulating AR objects */ import { NativeModules, Platform } from 'react-native'; import { ARSessionManager } from './ARSessionManager'; import { ARModelMaterial } from './types'; import { ARHapticFeedback, ARHapticFeedbackType } from './ARHapticFeedback'; // Easing functions for animations export enum ARAnimationEasing { LINEAR = 'linear', EASE_IN = 'easeIn', EASE_OUT = 'easeOut', EASE_IN_OUT = 'easeInOut', BOUNCE = 'bounce', ELASTIC = 'elastic', } // Animation properties that can be animated export enum ARAnimationProperty { POSITION_X = 'position.x', POSITION_Y = 'position.y', POSITION_Z = 'position.z', ROTATION_X = 'rotation.x', ROTATION_Y = 'rotation.y', ROTATION_Z = 'rotation.z', SCALE = 'scale', OPACITY = 'opacity', } // Animation configuration export interface ARAnimationConfig { /** * Duration of the animation in milliseconds */ duration: number; /** * Easing function to use */ easing?: ARAnimationEasing; /** * Delay before starting animation in milliseconds */ delay?: number; /** * Whether to loop the animation */ loop?: boolean; /** * Number of times to repeat the animation * If loop is true, this is ignored */ repeatCount?: number; /** * Whether to animate in reverse when repeating * (forward, then backward, then forward, etc.) */ autoReverse?: boolean; /** * Callback when animation completes */ onComplete?: () => void; } /** * Object placement animation types */ export enum ARPlacementAnimationType { NONE = 'none', FADE_IN = 'fadeIn', DROP = 'drop', GROW = 'grow', BOUNCE = 'bounce', SPIRAL = 'spiral', APPEAR = 'appear', SLIDE_IN = 'slideIn', ASSEMBLE = 'assemble', } /** * Configuration for object placement animations */ export interface ARPlacementAnimationConfig { /** * Type of placement animation to use */ type: ARPlacementAnimationType; /** * Duration of the placement animation in milliseconds */ duration?: number; /** * Easing function to use for the placement animation */ easing?: ARAnimationEasing; /** * Direction for directional animations (like slideIn) * Values: 'top', 'bottom', 'left', 'right', 'forward', 'backward' */ direction?: string; /** * Initial offset for animations that start from an offset position * (in meters) */ offset?: number; /** * Initial scale for animations that start from a different scale */ initialScale?: number; /** * Intensity of the animation effect (0.0 - 1.0) * Higher values make the effect more pronounced */ intensity?: number; /** * Whether to add a small random variation to the animation * to make multiple objects placed at once look more natural */ randomizeSlightly?: boolean; /** * Optional callback when placement animation completes */ onComplete?: () => void; } // Selection animation configuration export interface ARSelectionAnimationConfig { /** * Whether to enable selection animation */ enabled: boolean; /** * Y-axis float height when selected (in meters) */ floatHeight?: number; /** * Duration of the float animation (ms) */ floatDuration?: number; /** * Whether to add a pulsing scale effect */ pulseScale?: boolean; /** * Scale factor for the pulse effect (1.0 = no scale change) */ pulseScaleFactor?: number; /** * Whether to add a rotating effect */ rotate?: boolean; /** * Rotation speed in radians per second */ rotationSpeed?: number; /** * Custom material to apply when selected */ selectionMaterial?: { color?: string; opacity?: number; emissive?: string; }; } /** * Animation utilities for AR objects */ export class ARAnimationUtils { private static defaultSelectionConfig: ARSelectionAnimationConfig = { enabled: true, floatHeight: 0.05, floatDuration: 1000, pulseScale: true, pulseScaleFactor: 1.05, rotate: false, rotationSpeed: Math.PI / 4, }; // Store animation IDs by object ID private static activeAnimations: Map = new Map(); // Store original positions for selected objects private static originalPositions: Map = new Map(); // Selected object ID private static selectedObjectId: string | null = null; /** * Animates a property of an AR object */ static async animateProperty( objectId: string, property: ARAnimationProperty, toValue: number, config: ARAnimationConfig ): Promise { // Get the native module const NativeAR = NativeModules.ARModule; // Create the animation const animationId = await NativeAR.createPropertyAnimation( objectId, property, toValue, { duration: config.duration, easing: config.easing || ARAnimationEasing.EASE_OUT, delay: config.delay || 0, loop: config.loop || false, repeatCount: config.repeatCount || 0, autoReverse: config.autoReverse || false, } ); // Store the animation ID if (!this.activeAnimations.has(objectId)) { this.activeAnimations.set(objectId, []); } this.activeAnimations.get(objectId)?.push(animationId); // Start the animation await NativeAR.startAnimation(animationId); // Setup completion callback if provided if (config.onComplete) { NativeAR.onAnimationComplete(animationId, () => { // Remove from active animations const animations = this.activeAnimations.get(objectId) || []; const index = animations.indexOf(animationId); if (index !== -1) { animations.splice(index, 1); if (animations.length === 0) { this.activeAnimations.delete(objectId); } else { this.activeAnimations.set(objectId, animations); } } // Call the completion callback config.onComplete?.(); }); } return animationId; } /** * Stops all animations for an object */ static async stopAllAnimations(objectId: string): Promise { const animations = this.activeAnimations.get(objectId); if (!animations || animations.length === 0) { return; } const NativeAR = NativeModules.ARModule; // Stop all animations for (const animationId of animations) { await NativeAR.stopAnimation(animationId); } // Clear animations for this object this.activeAnimations.delete(objectId); } /** * Sets the current selected object * Applies selection animations based on config */ static async selectObject( objectId: string, config?: Partial ): Promise { // Deselect current object if any if (this.selectedObjectId) { await this.deselectObject(); } // Merge config with defaults const selectionConfig = { ...this.defaultSelectionConfig, ...config, }; if (!selectionConfig.enabled) { this.selectedObjectId = objectId; return; } // Trigger haptic feedback for object selection ARHapticFeedback.objectSelected(); // Get current position const properties = await ARSessionManager.getObjectProperties(objectId); this.originalPositions.set(objectId, properties.position); this.selectedObjectId = objectId; // Float animation if (selectionConfig.floatHeight && selectionConfig.floatHeight > 0) { const newY = properties.position.y + selectionConfig.floatHeight; await this.animateProperty( objectId, ARAnimationProperty.POSITION_Y, newY, { duration: selectionConfig.floatDuration || 1000, easing: ARAnimationEasing.EASE_OUT, autoReverse: true, loop: true, } ); } // Pulse scale animation if (selectionConfig.pulseScale && selectionConfig.pulseScaleFactor) { const newScale = properties.scale * selectionConfig.pulseScaleFactor; await this.animateProperty( objectId, ARAnimationProperty.SCALE, newScale, { duration: 1200, easing: ARAnimationEasing.EASE_IN_OUT, autoReverse: true, loop: true, } ); } // Rotation animation if (selectionConfig.rotate && selectionConfig.rotationSpeed) { // We'll animate in a full circle (2π radians) const fullRotation = properties.rotation.y + Math.PI * 2; const rotationDuration = (Math.PI * 2) / selectionConfig.rotationSpeed * 1000; await this.animateProperty( objectId, ARAnimationProperty.ROTATION_Y, fullRotation, { duration: rotationDuration, easing: ARAnimationEasing.LINEAR, loop: true, } ); } // Apply selection material if provided if (selectionConfig.selectionMaterial) { await ARSessionManager.updateMaterial(objectId, '', selectionConfig.selectionMaterial); } } /** * Deselects the current selected object * Stops all selection animations and returns object to original position */ static async deselectObject(): Promise { if (!this.selectedObjectId) { return; } const objectId = this.selectedObjectId; this.selectedObjectId = null; // Stop all animations await this.stopAllAnimations(objectId); // Return to original position const originalPosition = this.originalPositions.get(objectId); if (originalPosition) { await this.animateProperty( objectId, ARAnimationProperty.POSITION_Y, originalPosition.y, { duration: 500, easing: ARAnimationEasing.EASE_OUT, onComplete: () => { this.originalPositions.delete(objectId); } } ); } } /** * Animates an object to a new position */ static async animatePosition( objectId: string, position: { x: number; y: number; z: number }, duration = 1000, easing = ARAnimationEasing.EASE_OUT ): Promise { const config: ARAnimationConfig = { duration, easing, }; // Animate each axis const xAnimation = this.animateProperty( objectId, ARAnimationProperty.POSITION_X, position.x, config ); const yAnimation = this.animateProperty( objectId, ARAnimationProperty.POSITION_Y, position.y, config ); const zAnimation = this.animateProperty( objectId, ARAnimationProperty.POSITION_Z, position.z, config ); await Promise.all([xAnimation, yAnimation, zAnimation]); } /** * Animates an object's rotation */ static async animateRotation( objectId: string, rotation: { x: number; y: number; z: number }, duration = 1000, easing = ARAnimationEasing.EASE_OUT ): Promise { const config: ARAnimationConfig = { duration, easing, }; // Animate each axis const xAnimation = this.animateProperty( objectId, ARAnimationProperty.ROTATION_X, rotation.x, config ); const yAnimation = this.animateProperty( objectId, ARAnimationProperty.ROTATION_Y, rotation.y, config ); const zAnimation = this.animateProperty( objectId, ARAnimationProperty.ROTATION_Z, rotation.z, config ); await Promise.all([xAnimation, yAnimation, zAnimation]); } /** * Animates an object's scale */ static async animateScale( objectId: string, scale: number, duration = 1000, easing = ARAnimationEasing.EASE_OUT ): Promise { await this.animateProperty( objectId, ARAnimationProperty.SCALE, scale, { duration, easing, } ); } /** * Creates a bouncing animation for an object */ static async animateBounce( objectId: string, height = 0.1, duration = 1000, repeatCount = 3 ): Promise { // Get current position const properties = await ARSessionManager.getObjectProperties(objectId); const originalY = properties.position.y; await this.animateProperty( objectId, ARAnimationProperty.POSITION_Y, originalY + height, { duration: duration / 2, easing: ARAnimationEasing.EASE_OUT, autoReverse: true, repeatCount: repeatCount * 2, onComplete: async () => { // Ensure it ends at the original position await ARSessionManager.moveObject(objectId, { ...properties.position, y: originalY, }); } } ); } /** * Creates a wobble rotation animation (like the object is unstable) */ static async animateWobble( objectId: string, intensity = 0.1, duration = 2000, repeatCount = 2 ): Promise { // Get current rotation const properties = await ARSessionManager.getObjectProperties(objectId); const originalRotation = properties.rotation; // Animate small rotations on each axis const animations = [ this.animateProperty( objectId, ARAnimationProperty.ROTATION_X, originalRotation.x + intensity, { duration: duration / 4, easing: ARAnimationEasing.EASE_IN_OUT, autoReverse: true, repeatCount: repeatCount * 4, } ), this.animateProperty( objectId, ARAnimationProperty.ROTATION_Z, originalRotation.z + intensity, { duration: duration / 3, easing: ARAnimationEasing.EASE_IN_OUT, autoReverse: true, repeatCount: repeatCount * 3, onComplete: async () => { // Reset to original rotation await ARSessionManager.rotateObject(objectId, originalRotation); } } ), ]; await Promise.all(animations); } /** * Default placement animation configuration */ private static defaultPlacementConfig: Partial = { duration: 800, easing: ARAnimationEasing.EASE_OUT, offset: 0.3, initialScale: 0.01, intensity: 0.7, randomizeSlightly: true, }; /** * Applies a placement animation to an object that's just been placed in the AR scene * @param objectId ID of the object to animate * @param config Animation configuration * @returns Promise that resolves when the placement animation has started * (Not when it's complete, unless you use the onComplete callback) */ static async animatePlacement( objectId: string, config: Partial ): Promise { // Merge with default config const fullConfig: ARPlacementAnimationConfig = { type: config.type || ARPlacementAnimationType.GROW, ...this.defaultPlacementConfig, ...config, }; // Trigger haptic feedback based on animation type // Different animations get different haptic feedback types switch (fullConfig.type) { case ARPlacementAnimationType.BOUNCE: case ARPlacementAnimationType.DROP: // Stronger feedback for drop/bounce animations ARHapticFeedback.trigger(ARHapticFeedbackType.HEAVY); break; case ARPlacementAnimationType.APPEAR: // Success feedback for appear animation ARHapticFeedback.trigger(ARHapticFeedbackType.SUCCESS); break; case ARPlacementAnimationType.ASSEMBLE: // Custom pattern for assemble animation ARHapticFeedback.customPattern([0, 30, 20, 40, 20, 20], false); break; default: // Default placement feedback ARHapticFeedback.objectPlaced(); break; } // Get current object properties const properties = await ARSessionManager.getObjectProperties(objectId); // Apply a slight randomization to make multiple placements look more natural const randomFactor = fullConfig.randomizeSlightly ? 1 + (Math.random() * 0.2 - 0.1) // +/- 10% : 1; const duration = Math.round((fullConfig.duration || 800) * randomFactor); // Execute the appropriate animation based on the type switch (fullConfig.type) { case ARPlacementAnimationType.FADE_IN: await this._animateFadeIn(objectId, properties, fullConfig, duration); break; case ARPlacementAnimationType.DROP: await this._animateDrop(objectId, properties, fullConfig, duration); break; case ARPlacementAnimationType.GROW: await this._animateGrow(objectId, properties, fullConfig, duration); break; case ARPlacementAnimationType.BOUNCE: await this._animateBounceIn(objectId, properties, fullConfig, duration); break; case ARPlacementAnimationType.SPIRAL: await this._animateSpiral(objectId, properties, fullConfig, duration); break; case ARPlacementAnimationType.APPEAR: await this._animateAppear(objectId, properties, fullConfig, duration); break; case ARPlacementAnimationType.SLIDE_IN: await this._animateSlideIn(objectId, properties, fullConfig, duration); break; case ARPlacementAnimationType.ASSEMBLE: await this._animateAssemble(objectId, properties, fullConfig, duration); break; case ARPlacementAnimationType.NONE: default: // No animation, just ensure the object is visible const NativeAR = NativeModules.ARModule; if (fullConfig.onComplete) { setTimeout(fullConfig.onComplete, 10); } break; } } /** * Fade-in placement animation */ private static async _animateFadeIn( objectId: string, properties: any, config: ARPlacementAnimationConfig, duration: number ): Promise { // Start with opacity 0 and animate to 1 const NativeAR = NativeModules.ARModule; // Set initial opacity to 0 await NativeAR.setObjectProperty(objectId, 'opacity', 0); // Animate to full opacity await this.animateProperty( objectId, ARAnimationProperty.OPACITY, 1.0, { duration, easing: config.easing || ARAnimationEasing.EASE_OUT, onComplete: config.onComplete, } ); } /** * Drop-from-above placement animation */ private static async _animateDrop( objectId: string, properties: any, config: ARPlacementAnimationConfig, duration: number ): Promise { // Calculate the drop height const offset = config.offset || 0.3; // 30cm default const intensity = config.intensity || 0.7; const dropHeight = properties.position.y + (offset * intensity); // Move the object up first await ARSessionManager.moveObject(objectId, { ...properties.position, y: dropHeight, }); // Then animate down with a bounce effect await this.animateProperty( objectId, ARAnimationProperty.POSITION_Y, properties.position.y, { duration, easing: ARAnimationEasing.BOUNCE, onComplete: config.onComplete, } ); } /** * Grow-from-small placement animation */ private static async _animateGrow( objectId: string, properties: any, config: ARPlacementAnimationConfig, duration: number ): Promise { const initialScale = config.initialScale || 0.01; const targetScale = properties.scale; // Set initial scale await ARSessionManager.scaleObject(objectId, initialScale); // Animate to target scale await this.animateProperty( objectId, ARAnimationProperty.SCALE, targetScale, { duration, easing: config.easing || ARAnimationEasing.EASE_OUT, onComplete: config.onComplete, } ); } /** * Bounce-in placement animation */ private static async _animateBounceIn( objectId: string, properties: any, config: ARPlacementAnimationConfig, duration: number ): Promise { const initialScale = config.initialScale || 0.2; const targetScale = properties.scale; const overshootScale = targetScale * 1.2; // Set initial small scale await ARSessionManager.scaleObject(objectId, initialScale); // First grow beyond target scale await this.animateProperty( objectId, ARAnimationProperty.SCALE, overshootScale, { duration: duration * 0.7, easing: ARAnimationEasing.EASE_OUT, } ); // Then settle to target scale await this.animateProperty( objectId, ARAnimationProperty.SCALE, targetScale, { duration: duration * 0.3, easing: ARAnimationEasing.EASE_IN_OUT, onComplete: config.onComplete, } ); } /** * Spiral-in placement animation */ private static async _animateSpiral( objectId: string, properties: any, config: ARPlacementAnimationConfig, duration: number ): Promise { const initialScale = config.initialScale || 0.01; const targetScale = properties.scale; // Set initial small scale and position a bit below the target position await ARSessionManager.scaleObject(objectId, initialScale); await ARSessionManager.moveObject(objectId, { ...properties.position, y: properties.position.y - 0.05, }); // Setup spiral parameters const startRotation = properties.rotation.y; const endRotation = startRotation + (Math.PI * 2); // Full 360° rotation // Start the spiral animations const scaleAnim = this.animateProperty( objectId, ARAnimationProperty.SCALE, targetScale, { duration, easing: config.easing || ARAnimationEasing.EASE_OUT, } ); const rotateAnim = this.animateProperty( objectId, ARAnimationProperty.ROTATION_Y, endRotation, { duration, easing: ARAnimationEasing.EASE_OUT, } ); const moveAnim = this.animateProperty( objectId, ARAnimationProperty.POSITION_Y, properties.position.y, { duration, easing: ARAnimationEasing.EASE_OUT, onComplete: config.onComplete, } ); await Promise.all([scaleAnim, rotateAnim, moveAnim]); } /** * Appear with a flash effect */ private static async _animateAppear( objectId: string, properties: any, config: ARPlacementAnimationConfig, duration: number ): Promise { const initialScale = 0.9; const flashScale = 1.15; const targetScale = properties.scale; // Set up a custom emissive material for the flash const originalMaterial = await this._getObjectMaterial(objectId); // Flash material with emissive property const flashMaterial: Partial = { emissive: '#ffffff', emissiveIntensity: 1.0, }; // Set initial scale await ARSessionManager.scaleObject(objectId, initialScale); // Apply flash material await ARSessionManager.updateMaterial(objectId, '', flashMaterial); // Quick expand with flash await this.animateProperty( objectId, ARAnimationProperty.SCALE, flashScale, { duration: duration * 0.3, easing: ARAnimationEasing.EASE_OUT, } ); // Reset material await ARSessionManager.updateMaterial(objectId, '', originalMaterial); // Settle to actual size await this.animateProperty( objectId, ARAnimationProperty.SCALE, targetScale, { duration: duration * 0.7, easing: ARAnimationEasing.EASE_OUT, onComplete: config.onComplete, } ); } /** * Slide in from a direction */ private static async _animateSlideIn( objectId: string, properties: any, config: ARPlacementAnimationConfig, duration: number ): Promise { const offset = config.offset || 0.5; // 50cm default const direction = config.direction || 'forward'; // Calculate the starting position based on direction const startPosition = { ...properties.position }; switch (direction) { case 'top': startPosition.y += offset; break; case 'bottom': startPosition.y -= offset; break; case 'left': startPosition.x -= offset; break; case 'right': startPosition.x += offset; break; case 'forward': startPosition.z -= offset; break; case 'backward': default: startPosition.z += offset; break; } // Set initial position await ARSessionManager.moveObject(objectId, startPosition); // Animate to target position await this.animatePosition( objectId, properties.position, duration, config.easing || ARAnimationEasing.EASE_OUT ); // Call completion callback if provided if (config.onComplete) { config.onComplete(); } } /** * Assemble the object from separate parts * (visual effect using opacity, scale and position) */ private static async _animateAssemble( objectId: string, properties: any, config: ARPlacementAnimationConfig, duration: number ): Promise { // For the assemble effect, we'll use a combination of // opacity, scale, and small position shifts // Setup initial "disassembled" state await ARSessionManager.updateMaterial(objectId, '', { opacity: 0.3 }); await ARSessionManager.scaleObject(objectId, properties.scale * 0.9); // Small random offset for each axis to create "scattered parts" look const offsetRange = 0.05; const randomOffsets = { x: (Math.random() - 0.5) * offsetRange, y: (Math.random() - 0.5) * offsetRange, z: (Math.random() - 0.5) * offsetRange, }; await ARSessionManager.moveObject(objectId, { x: properties.position.x + randomOffsets.x, y: properties.position.y + randomOffsets.y, z: properties.position.z + randomOffsets.z, }); // First phase - move parts toward center position const positionAnim = this.animatePosition( objectId, properties.position, duration * 0.7, ARAnimationEasing.EASE_OUT ); // Second phase - increase opacity as parts come together const opacityAnim = this.animateProperty( objectId, ARAnimationProperty.OPACITY, 1.0, { duration: duration * 0.9, easing: ARAnimationEasing.EASE_IN_OUT, } ); // Final phase - set correct scale await Promise.all([positionAnim, opacityAnim]); await this.animateProperty( objectId, ARAnimationProperty.SCALE, properties.scale, { duration: duration * 0.3, easing: ARAnimationEasing.EASE_OUT, onComplete: config.onComplete, } ); } /** * Helper to get an object's current material */ private static async _getObjectMaterial(objectId: string): Promise { try { const NativeAR = NativeModules.ARModule; return await NativeAR.getMaterial(objectId, ''); } catch (error) { // Default material if we can't fetch current return {}; } } }