|
| 1 | +"use client"; |
| 2 | +import { useGlobalStore, useImageExportStore, usePlotStore } from '@/utils/GlobalStates' |
| 3 | +import { invalidate, useThree } from '@react-three/fiber'; |
| 4 | +import React, { useEffect, useRef } from 'react' |
| 5 | +import { useShallow } from 'zustand/shallow' |
| 6 | +import * as THREE from 'three' |
| 7 | +import { lerp } from 'three/src/math/MathUtils.js'; |
| 8 | + |
| 9 | + |
| 10 | +export const KeyFramePreviewer = () => { |
| 11 | + const {keyFrames, currentFrame, previewKeyFrames, frames, |
| 12 | + frameRate, useTime, timeRate, orbit, loopTime,} = useImageExportStore(useShallow(state => ({ |
| 13 | + keyFrames:state.keyFrames, currentFrame:state.currentFrame, previewKeyFrames:state.previewKeyFrames, |
| 14 | + frames:state.frames, frameRate:state.frameRate, useTime:state.useTime, timeRate:state.timeRate, |
| 15 | + orbit:state.orbit, loopTime:state.loopTime |
| 16 | + }))) |
| 17 | + |
| 18 | + const {camera} = useThree(); |
| 19 | + const isAnimating = useRef(false) |
| 20 | + const originalAnimProg = useRef<number>(0) |
| 21 | + |
| 22 | + const KeyFrameLerper = (startState: Record<string,any>, endState:Record<string,any>, alpha:number, useCamera=true) => { |
| 23 | + const startVizState = startState["visual"] |
| 24 | + const endVizState = endState["visual"] |
| 25 | + const lerpedVizState: Record<string, any> = {}; |
| 26 | + const lerpedCamState: Record<string, any> = {}; |
| 27 | + Object.keys(startVizState).forEach(key => { |
| 28 | + const sourceValue = startVizState[key]; |
| 29 | + const targetValue = endVizState[key]; |
| 30 | + |
| 31 | + // Check if both values are numbers |
| 32 | + if (typeof sourceValue === 'number' && typeof targetValue === 'number') { |
| 33 | + lerpedVizState[key] = lerp(sourceValue, targetValue, alpha); |
| 34 | + } |
| 35 | + else if (sourceValue.length){ // If Array |
| 36 | + lerpedVizState[key] = [] |
| 37 | + for (let i = 0; i < sourceValue.length; i++){ |
| 38 | + lerpedVizState[key][i] = lerp(sourceValue[i], targetValue[i], alpha); |
| 39 | + } |
| 40 | + } |
| 41 | + // Handle Vector3, arrays, or other lerpable objects |
| 42 | + else if (sourceValue?.lerp && typeof sourceValue.lerp === 'function') { |
| 43 | + lerpedVizState[key] = sourceValue.clone().lerp(targetValue, alpha); |
| 44 | + } |
| 45 | + // For non-numeric values, just copy from target |
| 46 | + else { |
| 47 | + lerpedVizState[key] = targetValue; |
| 48 | + } |
| 49 | + }); |
| 50 | + usePlotStore.setState(lerpedVizState) |
| 51 | + |
| 52 | + if (useCamera){ // Don't lerp camera if orbiting |
| 53 | + const startCamState = startState["camera"] |
| 54 | + const endCamState = endState["camera"] |
| 55 | + Object.keys(startCamState).forEach(key => { |
| 56 | + const sourceValue = startCamState[key]; |
| 57 | + const targetValue = endCamState[key]; |
| 58 | + if (sourceValue.isEuler){ |
| 59 | + const startQuat = new THREE.Quaternion().setFromEuler(sourceValue); |
| 60 | + const endQuat = new THREE.Quaternion().setFromEuler(targetValue); |
| 61 | + |
| 62 | + const resultQuat = new THREE.Quaternion().copy(startQuat).slerp(endQuat, alpha); |
| 63 | + lerpedCamState[key]= new THREE.Euler().setFromQuaternion(resultQuat); |
| 64 | + |
| 65 | + } else{ |
| 66 | + lerpedCamState[key] = sourceValue.clone().lerp(targetValue,alpha) |
| 67 | + } |
| 68 | + }); |
| 69 | + camera.position.copy(lerpedCamState.position) |
| 70 | + camera.rotation.copy(lerpedCamState.rotation) |
| 71 | + camera.updateProjectionMatrix(); |
| 72 | + invalidate(); |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + |
| 77 | + // ----- PREVIEW FUNCTIONS ---- // |
| 78 | + |
| 79 | + // PREVIEW KEYFRAME |
| 80 | + useEffect(()=>{ |
| 81 | + if (!keyFrames || isAnimating.current) return; |
| 82 | + const keyFrameList = Array.from(keyFrames.keys()).sort((a, b) => a - b) |
| 83 | + const keyFrameIdx = keyFrameList.findLastIndex(n => n <= currentFrame) |
| 84 | + const startFrame = keyFrameList[keyFrameIdx] |
| 85 | + const origCamera = keyFrames.get(keyFrameList[0]).camera |
| 86 | + const originalPos = { |
| 87 | + x: origCamera.position.x, |
| 88 | + y: origCamera.position.y, |
| 89 | + z: origCamera.position.z |
| 90 | + }; |
| 91 | + const radius = Math.sqrt(originalPos.x ** 2 + originalPos.z ** 2); |
| 92 | + const originalAngle = Math.atan2(originalPos.x, originalPos.z); |
| 93 | + if (keyFrameIdx+1 < keyFrameList.length){ |
| 94 | + const endFrame = keyFrameList[keyFrameIdx+1] |
| 95 | + const thisFrames = endFrame-startFrame; |
| 96 | + const alpha = Math.max(currentFrame-startFrame, 0)/thisFrames; |
| 97 | + const startState = keyFrames?.get(startFrame) |
| 98 | + const endState = keyFrames?.get(endFrame) |
| 99 | + KeyFrameLerper(startState, endState, alpha, !orbit) |
| 100 | + } else { |
| 101 | + const {visual, camera:cameraState} = keyFrames.get(startFrame) |
| 102 | + usePlotStore.setState(visual) |
| 103 | + if (!orbit){ |
| 104 | + camera.position.copy(cameraState.position) |
| 105 | + camera.rotation.copy(cameraState.rotation) |
| 106 | + camera.updateProjectionMatrix(); |
| 107 | + invalidate(); |
| 108 | + } |
| 109 | + } |
| 110 | + if (orbit){ |
| 111 | + const angle = (currentFrame / (frames+1)) * Math.PI * 2; |
| 112 | + const newAngle = originalAngle + angle; |
| 113 | + camera.position.x = radius * Math.sin(newAngle); |
| 114 | + camera.position.z = radius * Math.cos(newAngle); |
| 115 | + camera.lookAt(0, 0, 0); |
| 116 | + camera.updateProjectionMatrix(); |
| 117 | + invalidate(); |
| 118 | + } |
| 119 | + },[currentFrame]) |
| 120 | + |
| 121 | + // PREVIEW ANIMATION |
| 122 | + useEffect(()=>{ |
| 123 | + if (!keyFrames || isAnimating.current) return; |
| 124 | + const {animProg, setAnimProg} = usePlotStore.getState() |
| 125 | + originalAnimProg.current = animProg |
| 126 | + const keyFrameList = Array.from(keyFrames.keys()).sort((a, b) => a - b) |
| 127 | + const timeRatio = timeRate/frameRate; |
| 128 | + const {dataShape} = useGlobalStore.getState() |
| 129 | + const timeFrames = dataShape[dataShape.length-3] |
| 130 | + const dt = 1/timeFrames |
| 131 | + |
| 132 | + let keyFrameIdx = 0; |
| 133 | + let frame = 0; |
| 134 | + |
| 135 | + const origCamera = keyFrames.get(keyFrameList[0]).camera |
| 136 | + const originalPos = { |
| 137 | + x: origCamera.position.x, |
| 138 | + y: origCamera.position.y, |
| 139 | + z: origCamera.position.z |
| 140 | + }; |
| 141 | + const radius = Math.sqrt(originalPos.x ** 2 + originalPos.z ** 2); |
| 142 | + const originalAngle = Math.atan2(originalPos.x, originalPos.z); |
| 143 | + |
| 144 | + const intervalId = setInterval(()=>{ |
| 145 | + if (frame >= frames) { |
| 146 | + clearInterval(intervalId); |
| 147 | + isAnimating.current = false; |
| 148 | + setAnimProg(originalAnimProg.current) |
| 149 | + return; |
| 150 | + } |
| 151 | + useImageExportStore.getState().setCurrentFrame(frame) |
| 152 | + const startFrame = keyFrameList[keyFrameIdx]; |
| 153 | + if (keyFrameIdx + 1 < keyFrameList.length) { |
| 154 | + if (useTime) { |
| 155 | + let newProg = dt * Math.floor(frame * timeRatio) + animProg; |
| 156 | + newProg = loopTime ? newProg - Math.floor(newProg) : Math.min(newProg, 1); |
| 157 | + setAnimProg(newProg); |
| 158 | + } |
| 159 | + const endFrame = keyFrameList[keyFrameIdx + 1]; |
| 160 | + const thisFrames = endFrame - startFrame; |
| 161 | + const alpha = Math.max(frame - startFrame, 0) / thisFrames; |
| 162 | + const startState = keyFrames.get(startFrame); |
| 163 | + const endState = keyFrames.get(endFrame); |
| 164 | + KeyFrameLerper(startState, endState, alpha, !orbit); // Don't lerp camera if orbiting |
| 165 | + if (frame >= endFrame) keyFrameIdx++; |
| 166 | + } else { |
| 167 | + const { visual, camera: cameraState } = keyFrames.get(startFrame); |
| 168 | + usePlotStore.setState(visual); |
| 169 | + if (!orbit){ // Don't lerp camera if orbiting |
| 170 | + camera.position.copy(cameraState.position); |
| 171 | + camera.rotation.copy(cameraState.rotation); |
| 172 | + camera.updateProjectionMatrix(); |
| 173 | + invalidate(); |
| 174 | + } |
| 175 | + } |
| 176 | + if (orbit){ |
| 177 | + const angle = (frame / (frames+1)) * Math.PI * 2; |
| 178 | + const newAngle = originalAngle + angle; |
| 179 | + camera.position.x = radius * Math.sin(newAngle); |
| 180 | + camera.position.z = radius * Math.cos(newAngle); |
| 181 | + camera.lookAt(0, 0, 0); |
| 182 | + camera.updateProjectionMatrix(); |
| 183 | + invalidate(); |
| 184 | + } |
| 185 | + frame ++; |
| 186 | + }, 1000/frameRate) |
| 187 | + |
| 188 | + isAnimating.current= true; |
| 189 | + |
| 190 | + return () => clearInterval(intervalId); |
| 191 | + |
| 192 | + },[previewKeyFrames]) |
| 193 | + return null |
| 194 | +} |
| 195 | + |
| 196 | + |
0 commit comments