UNPKG

@wandelbots/wandelbots-js-react-components

Version:

React UI toolkit for building applications on top of the Wandelbots platform

380 lines (334 loc) 11.9 kB
import React from "react" /** * Smooth value interpolation utility using spring physics with tension and friction. * Designed for React Three Fiber applications with smooth, natural animations. * * Features: * - Spring physics with configurable tension and friction * - Frame-rate independent using delta timing * - Handles irregular frame timing and rapid target updates * - Manual update() calls for useFrame integration (no automatic RAF loop) * - Direct mutation for performance * * @example * ```tsx * // Basic spring animation * const interpolator = new ValueInterpolator([0, 0, 0], { * tension: 120, // Higher = faster, stiffer spring (default: 120) * friction: 20, // Higher = more damping, less oscillation (default: 20) * onChange: (values) => { * robot.joints.forEach((joint, i) => { * joint.rotation.y = values[i] * }) * } * }) * * interpolator.setTarget([1.5, -0.8, 2.1]) * * // React Three Fiber usage * function MyComponent() { * const [interpolator] = useInterpolation([0, 0, 0]) * * useFrame((state, delta) => { * interpolator.update(delta) * }) * * useEffect(() => { * interpolator.setTarget([1, 2, 3]) * }, []) * * return <mesh position={interpolator.getCurrentValues()} /> * } * ``` */ export interface InterpolationOptions { /** Spring tension (higher = faster, stiffer spring) - default: 120 */ tension?: number /** Spring friction (higher = more damping, less oscillation) - default: 20 */ friction?: number /** Minimum threshold to consider interpolation complete - default: 0.001 */ threshold?: number /** Callback when values change during interpolation */ onChange?: (values: number[]) => void /** Callback when interpolation reaches target values */ onComplete?: (values: number[]) => void } export class ValueInterpolator { private currentValues: number[] = [] private targetValues: number[] = [] private previousTargetValues: number[] = [] private targetUpdateTime: number = 0 private animationId: number | null = null private options: Required<InterpolationOptions> private updateCount: number = 0 // Track how many updates have occurred // Spring physics state private velocities: number[] = [] constructor( initialValues: number[] = [], options: InterpolationOptions = {}, ) { this.options = { tension: 120, friction: 20, threshold: 0.001, onChange: () => {}, onComplete: () => {}, ...options, } this.currentValues = [...initialValues] this.targetValues = [...initialValues] this.previousTargetValues = [...initialValues] this.velocities = new Array(initialValues.length).fill(0) this.targetUpdateTime = performance.now() this.updateCount = 0 } /** * Update interpolation using spring physics * * Call this method every frame for smooth animation. In React Three Fiber, * call from within useFrame callback with the provided delta time. * * @param delta - Time elapsed since last update in seconds (e.g., 1/60 ≈ 0.0167 for 60fps) * @returns true when interpolation is complete (all values reached their targets) */ update(delta: number = 1 / 60): boolean { let hasChanges = false let isComplete = true // Increment update counter for initialization smoothing this.updateCount++ // Limit delta to prevent physics instability during large frame drops const clampedDelta = Math.min(delta, 1 / 15) // Maximum 66ms frame time allowed // Apply gentle ramp-up for the first few frames to prevent initial jumpiness // Only apply reduced force for the very first frame to prevent jarring starts const initializationFactor = this.updateCount === 1 ? 0.7 // Slightly reduced force only on the very first frame : 1 for (let i = 0; i < this.currentValues.length; i++) { const current = this.currentValues[i] const target = this.targetValues[i] const velocity = this.velocities[i] // Calculate spring physics forces const displacement = target - current const springForce = displacement * this.options.tension * initializationFactor const dampingForce = velocity * this.options.friction // Calculate acceleration from net force (F = ma, assuming mass = 1) const acceleration = springForce - dampingForce // Integrate physics using Verlet method for numerical stability const newVelocity = velocity + acceleration * clampedDelta const newValue = current + newVelocity * clampedDelta // Determine if this value has settled (close to target with low velocity) const isValueComplete = Math.abs(displacement) < this.options.threshold && Math.abs(newVelocity) < this.options.threshold * 10 if (!isValueComplete) { isComplete = false // Continue spring animation this.currentValues[i] = newValue this.velocities[i] = newVelocity hasChanges = true } else { // Snap exactly to target when close enough (prevents endless micro-movements) if (this.currentValues[i] !== target) { this.currentValues[i] = target this.velocities[i] = 0 hasChanges = true } } } if (hasChanges) { this.options.onChange(this.currentValues) } if (isComplete) { this.options.onComplete(this.currentValues) } return isComplete } /** * Set new target values for the interpolation to move towards * * Includes smart blending for very rapid target updates (faster than 120fps) * to prevent jarring movements when targets change frequently. */ setTarget(newValues: number[]): void { const now = performance.now() const timeSinceLastUpdate = now - this.targetUpdateTime // Store previous target for smooth transitions this.previousTargetValues = [...this.targetValues] this.targetValues = [...newValues] this.targetUpdateTime = now // Reset update counter for smooth initialization when target changes this.updateCount = 0 // Apply target blending for extremely rapid updates to prevent jarring jumps // Only activates when targets change faster than 120fps (< 8ms between updates) // AND this is not the first target being set (avoid blending initial target with initial values) const isInitialTargetSet = this.previousTargetValues.every( (val, i) => val === this.currentValues[i], ) if ( timeSinceLastUpdate < 8 && timeSinceLastUpdate > 0 && this.previousTargetValues.length > 0 && !isInitialTargetSet // Don't blend if this is the first meaningful target change ) { // Blend between previous and new target based on time elapsed const blendFactor = Math.min(timeSinceLastUpdate / 8, 1) // 0 to 1 over 8ms for (let i = 0; i < this.targetValues.length; i++) { const prev = this.previousTargetValues[i] || 0 const next = newValues[i] || 0 // Only blend significant changes to avoid unnecessary smoothing const change = Math.abs(next - prev) if (change > 0.1) { this.targetValues[i] = prev + (next - prev) * blendFactor } } } // Ensure value and velocity arrays have matching lengths while (this.currentValues.length < newValues.length) { this.currentValues.push(newValues[this.currentValues.length]) this.velocities.push(0) // New values start with zero velocity } if (this.currentValues.length > newValues.length) { this.currentValues = this.currentValues.slice(0, newValues.length) this.velocities = this.velocities.slice(0, newValues.length) } // Does not start automatic interpolation - requires manual update() calls // This design prevents conflicts when using with React Three Fiber's useFrame } /** * Get a copy of all current interpolated values */ getCurrentValues(): number[] { return [...this.currentValues] } /** * Get a single interpolated value by its array index */ getValue(index: number): number { return this.currentValues[index] ?? 0 } /** * Check if automatic interpolation is currently running * * This only tracks auto-interpolation started with startAutoInterpolation(). * Manual update() calls are not tracked by this method. */ isInterpolating(): boolean { return this.animationId !== null } /** * Stop automatic interpolation if it's running * * This cancels the internal animation frame loop but does not affect * manual update() calls. */ stop(): void { if (this.animationId !== null) { cancelAnimationFrame(this.animationId) this.animationId = null } } /** * Instantly set values without interpolation */ setImmediate(values: number[]): void { this.stop() this.currentValues = [...values] this.targetValues = [...values] this.previousTargetValues = [...values] this.velocities = new Array(values.length).fill(0) // Reset velocities this.targetUpdateTime = performance.now() this.updateCount = 0 // Reset update counter this.options.onChange(this.currentValues) } /** * Update interpolation options */ updateOptions(newOptions: Partial<InterpolationOptions>): void { this.options = { ...this.options, ...newOptions } } /** * Start automatic interpolation with an animation loop * * This begins a requestAnimationFrame loop that calls update() automatically. * For React Three Fiber components, prefer using manual update() calls * within useFrame hooks instead. */ startAutoInterpolation(): void { this.startInterpolation() } /** * Clean up all resources and stop any running animations * * This cancels any active animation frames and resets internal state. * Call this when the component unmounts or is no longer needed. */ destroy(): void { this.stop() } private startInterpolation(): void { if (this.animationId !== null) { return // Already interpolating } this.animate() } private animate = (): void => { // Use delta timing with a fallback for consistent automatic animations const isComplete = this.update(1 / 60) // Simulate 60fps for auto-interpolation if (!isComplete) { this.animationId = requestAnimationFrame(this.animate) } else { this.animationId = null } } } /** * React hook for using the ValueInterpolator with useFrame * * This hook creates a ValueInterpolator that uses spring physics for smooth, * natural animations. Call interpolator.update(delta) in your useFrame callback. * * @example * ```tsx * function AnimatedMesh() { * const [interpolator] = useInterpolation([0, 0, 0], { * tension: 120, // Higher = faster spring * friction: 20 // Higher = more damping * }) * const meshRef = useRef() * * useFrame((state, delta) => { * if (interpolator.update(delta)) { * // Animation complete * } * // Apply current values directly to mesh * const [x, y, z] = interpolator.getCurrentValues() * meshRef.current.position.set(x, y, z) * }) * * return <mesh ref={meshRef} /> * } * ``` */ export function useInterpolation( initialValues: number[] = [], options: InterpolationOptions = {}, ): [ValueInterpolator] { const interpolatorRef = React.useRef<ValueInterpolator | null>(null) // Initialize interpolator if (!interpolatorRef.current) { interpolatorRef.current = new ValueInterpolator(initialValues, options) } // Update options when they change React.useEffect(() => { interpolatorRef.current?.updateOptions(options) }, [options]) // Cleanup on unmount React.useEffect(() => { return () => { interpolatorRef.current?.destroy() } }, []) return [interpolatorRef.current!] }