import * as React from "react"; import { Animation } from "babylonjs/Animations/animation"; import { Vector2, Vector3, Quaternion } from "babylonjs/Maths/math.vector"; import { Color3, Color4 } from "babylonjs/Maths/math.color"; import { Size } from "babylonjs/Maths/math.size"; import { EasingFunction } from "babylonjs/Animations/easing"; import { IAnimationKey } from "babylonjs/Animations/animationKey"; import { IKeyframeSvgPoint } from "./keyframeSvgPoint"; import { SvgDraggableArea } from "./svgDraggableArea"; import { Timeline } from "./timeline"; import { Notification } from "./notification"; import { GraphActionsBar } from "./graphActionsBar"; import { Scene } from "babylonjs/scene"; import { IAnimatable } from "babylonjs/Animations/animatable.interface"; import { Animatable } from "babylonjs/Animations/animatable"; import { TargetedAnimation } from "babylonjs/Animations/animationGroup"; import { EditorControls } from "./editorControls"; import { SelectedCoordinate } from "./animationListTree"; import { LockObject } from "../lockObject"; import { GlobalState } from "../../../../globalState"; import { Nullable } from "babylonjs/types"; import { Observer } from "babylonjs/Misc/observable"; import { ScaleLabel } from "./scale-label"; import { KeyframeSvgPoint } from "./keyframeSvgPoint"; require("./curveEditor.scss"); interface IAnimationCurveEditorComponentProps { playOrPause?: () => void; scene: Scene; entity: IAnimatable | TargetedAnimation; lockObject: LockObject; globalState: GlobalState; } /** * Represent an Axis of the canvas. This help us control the frame/panning */ interface ICanvasAxis { value: number; label: number; } /** * This help us identify the type of scaling on the selected animated property */ export enum CurveScale { float, radians, degrees, integers, default, } /** * This represents the visible selected keyframe in the canvas. If the keyframe is selected * we should be able to edit the frame and value. */ export interface IActionableKeyFrame { frame?: number | string; value?: any; } /** * Each curve is an SVG Path. This Interface give us its properties to render on the canvas. */ interface ICurveData { pathData: string; pathLength: number; domCurve: React.RefObject; color: string; id: string; } /** * The curve editor state */ interface IAnimationCurveEditorComponentState { /** If the editor is opened or closed */ isOpen: boolean; /** The currently selected animation from which the curves will render */ selected: Animation | null; /** A list of keyframes to be rendered as SVG Elements in the curve */ svgKeyframes: IKeyframeSvgPoint[] | undefined; /** The current frame in which the playhead is positioned */ currentFrame: number; /** The current value in which the playhead is positioned */ currentValue: number; /** The number of frame in which the current canvas is divided. The helps to render the frames in the graph editor */ frameAxisLength: ICanvasAxis[]; /** The number of values in which the current canvas is divided. The helps to render the frames in the graph editor. The current scale decides how these values are rendered*/ valueAxisLength: ICanvasAxis[]; /** If the selected keyframe should be treated as a flat tangent curve */ isFlatTangentMode: boolean; /** If the tangent mode is active */ isTangentMode: boolean; /** If the control points are broken or not */ isBrokenMode: boolean; /** If selected keyframe should render its selected control point to be linear or not */ lerpMode: boolean; /** The scale of the canvas */ scale: number; /** The offset of the playhead to position itself on the canvas */ playheadOffset: number; /** The error or warning message to display */ notification: string; /** The current selected point in the SVG curve */ currentPoint: SVGPoint | undefined; /** The start position of the playhead */ playheadPos: number; /** If the animation is playing or not */ isPlaying: boolean; /** The selected curve represented with an array of curves */ selectedPathData: ICurveData[] | undefined; /** The currently selected coordinate of a complex property (i.e. x, y, z) */ selectedCoordinate: number; /** The number of frames available in the canvas timeline*/ animationLimit: number; /** The currently animation frames per second */ fps: number; /** If the animation is set to loop or not while animation is playing */ isLooping: boolean; /** The amount of vertical panning the user has moved the canvas */ panningY: number; /** The amount of horizontal panning the user has moved the canvas */ panningX: number; /** If the canvas has finished repositioned itself */ repositionCanvas: boolean; /** The current selected keyframe on which we can perform actions (update frame and value) */ actionableKeyframe: IActionableKeyFrame; /** The current value scale */ valueScaleType: CurveScale; /** The amount scale to update valyes */ valueScale: number; /** The canvas proportion width */ canvasLength: number; /** The last frame to be created. */ lastKeyframeCreated: Nullable; /** The canvas scale width */ canvasWidthScale: number; /** How the resize of the canvas will change the value axis */ valuesPositionResize: number; /** How many frames are currently visible in the canvas */ framesInCanvasView: { from: number; to: number }; /** Limits the maximum frame from which a keyframe can move in the curve. So it wont overlap the curve*/ maxFrame: number | undefined; /** Limits the minimum frame from which a keyframe can move in the curve. So it wont overlap the curve*/ minFrame: number | undefined; /** How many frames have been increased or reduced on a windows resize event */ framesResized: number; } /** * Animation curve Editor Component * This component allows the use to create animations visualy from the Babylon.js playground. * The principal features for the curve editor are: * 1. Create animations for the selected entity properties. * 2. Edit the animations a selected entity has. * 3. You can load and save animations locally and from the snippet server. * 4. After creating an animation you need to add keyframes. * 5. You can update the frame and value of each frame. * @property {() => void} playOrPause is the event to stop or inititate the animation on the playground scene * @property {Scene} scene is the scene object from the playground * @property {IAnimatable | TargetedAnimation} entity represents the selected entity which will be the animations target * @property {LockObject} lockObject the inherited object to lock for modification with the Observable pattern * @property {GlobalState} globalState the playground globalstate * Check the IAnimationCurveEditorComponentState for state functionality */ export class AnimationCurveEditorComponent extends React.Component< IAnimationCurveEditorComponentProps, IAnimationCurveEditorComponentState > { // Global properties readonly _entityName: string; private _snippetUrl = "https://snippet.babylonjs.com"; // Default values private _heightScale: number = 100; private _scaleFactor: number = 2; private _currentScale: number = 10; private _pixelFrameUnit: number = 10; // SVG properties private _svgKeyframes: IKeyframeSvgPoint[] = []; private _isPlaying: boolean = false; private _graphCanvas: React.RefObject; private _editor: React.RefObject; private _editorWindow: Window; private _resizeId: ReturnType; private _svgCanvas: React.RefObject; private _isTargetedAnimation: boolean; private _resizedTimeline: number; private _onBeforeRenderObserver: Nullable>; private _mainAnimatable: Nullable; constructor(props: IAnimationCurveEditorComponentProps) { super(props); this._entityName = (this.props.entity as any).id; // Dom Refs to control events this._editor = React.createRef(); this._graphCanvas = React.createRef(); this._svgCanvas = React.createRef(); // Initial values const _canvasLength = 240; const valueInd = [2, 1.8, 1.6, 1.4, 1.2, 1, 0.8, 0.6, 0.4, 0.2, 0]; let initialSelection; let initialPathData; let initialLerpMode; // Controls wether the entity is a TargetedAnimation entity if (this.props.entity instanceof TargetedAnimation) { this._isTargetedAnimation = true; initialSelection = this.props.entity.animation; initialLerpMode = initialSelection !== undefined ? this.analyzeAnimationForLerp(initialSelection) : false; initialPathData = initialSelection !== undefined ? this.getPathData(initialSelection) : undefined; } else { this._isTargetedAnimation = false; let hasAnimations = this.props.entity.animations !== undefined || this.props.entity.animations !== null ? this.props.entity.animations : false; initialSelection = hasAnimations !== false ? hasAnimations && hasAnimations[0] : null; initialLerpMode = initialSelection !== undefined ? this.analyzeAnimationForLerp(this.props.entity.animations && initialSelection) : false; initialPathData = initialSelection && this.getPathData(initialSelection); initialPathData = initialPathData === null || initialPathData === undefined ? undefined : initialPathData; } // Verify the animations are stopped before rendering the editor this.stopAnimation(); // Set the initial state this.state = { selected: initialSelection, isOpen: true, svgKeyframes: this._svgKeyframes, currentFrame: 0, currentValue: 1, isFlatTangentMode: false, isTangentMode: false, isBrokenMode: false, lerpMode: initialLerpMode, playheadOffset: this._graphCanvas.current ? this._graphCanvas.current.children[0].clientWidth / (_canvasLength * 10) : 0, // Set default frame for visible canvas frameAxisLength: this.setFrameAxis(_canvasLength), // Set default values for the visible canvas valueAxisLength: new Array(10).fill(0).map((s, i) => { return { value: i * 10, label: valueInd[i] }; }), notification: "", currentPoint: undefined, scale: 1, playheadPos: 0, isPlaying: false, // The initial curve if exists on load selectedPathData: initialPathData, selectedCoordinate: 0, animationLimit: _canvasLength / 2, canvasLength: _canvasLength, fps: 60, isLooping: true, panningY: 0, panningX: 0, repositionCanvas: false, actionableKeyframe: { frame: undefined, value: undefined }, valueScaleType: CurveScale.default, valueScale: 2, lastKeyframeCreated: null, canvasWidthScale: 200, valuesPositionResize: 2, framesInCanvasView: { from: 0, to: 20 }, maxFrame: undefined, minFrame: undefined, framesResized: 0, }; } componentDidMount() { this.state.selected && this.selectAnimation(this.state.selected); // Control the window resize event if ( this._editor.current && this._editor.current.ownerDocument && this._editor.current.ownerDocument.defaultView ) { this._editorWindow = this._editor.current.ownerDocument.defaultView; this._editorWindow.addEventListener("resize", this.onWindowResizeWidth.bind(this)); } } /** * Connects scene frame with curve editor animation * @param prevProps previous Props * @param prevState previous State */ componentDidUpdate(prevProps: IAnimationCurveEditorComponentProps, prevState: any) { if (prevState.currentFrame !== this.state.currentFrame) { this.onCurrentFrameChangeScene(this.state.currentFrame); } } /** * Clean up observer and listeners */ componentWillUnmount() { this.playPause(0); if (this._onBeforeRenderObserver) { this.props.scene.onBeforeRenderObservable.remove(this._onBeforeRenderObserver); this._onBeforeRenderObserver = null; } this._editorWindow.removeEventListener("resize", this.onWindowResizeWidth); } /** * Set the frame on the animation * @param value number of frame to move animation */ onCurrentFrameChangeScene(value: number) { if (!this._mainAnimatable) { return; } this._mainAnimatable.goToFrame(value); } /** * Notifications * To add notification we set the state and clear to make the notification bar hide. */ clearNotification = () => { this.setState({ notification: "" }); }; /** * Zoom and Scroll * This section handles zoom and scroll * of the graph area. */ zoom = (e: React.WheelEvent) => { e.nativeEvent.stopImmediatePropagation(); let scaleX = 1; if (Math.sign(e.deltaY) === -1) { scaleX = this.state.scale; //- 0.01; //+ 0.01; } this.setState({ scale: scaleX }); }; /** * Returns Array with labels and values for Frame axis in Canvas */ setFrameAxis(currentLength: number) { let halfNegative = new Array(currentLength).fill(0).map((s, i) => { return { value: -i * 10, label: -i }; }); let halfPositive = new Array(currentLength).fill(0).map((s, i) => { return { value: i * 10, label: i }; }); return [...halfNegative, ...halfPositive]; } /** * Returns Array with labels, lines and values for Value axis in Canvas */ setValueLines() { const lineV = this._heightScale / 10; const initialValues = new Array(this._currentScale).fill(0).map((_, i) => { return { value: i * lineV, label: (this._scaleFactor * ((this._currentScale - i) / this._currentScale)).toFixed(2), }; }); initialValues.shift(); const valueHeight = Math.abs(Math.round(this.state.panningY / this._currentScale)); const sign = Math.sign(this.state.panningY); const pannedValues = new Array(valueHeight).fill(0).map((s, i) => { return sign === -1 ? { value: -i * lineV, label: ((i + this._currentScale) / (this._currentScale / this._scaleFactor)).toFixed(2), } : { value: (i + lineV) * this._currentScale, label: ((i * -1) / (this._currentScale / this._scaleFactor)).toFixed(2), }; }); return [...initialValues, ...pannedValues]; } /** * Creates a string id from animation name and the keyframe index * @param animationName Name of animation * @param keyframeIndex Index of keyframe */ encodeCurveId(animationName: string, keyframeIndex: number) { return animationName + "_" + keyframeIndex; } /** * Returns the animation keyframe index and the animation selected coordinate (x, y, z) * @param id curve id created with index and coordinate of animation */ decodeCurveId(id: string) { const order = parseInt(id.split("_")[3]); const coordinate = parseInt(id.split("_")[2]); return { order, coordinate }; } /** * Returns the value from a keyframe * @param id curve id */ getKeyframeValueFromAnimation(id: string) { const animation = this.state.selected as Animation; const { order, coordinate } = this.decodeCurveId(id); const keys = [...animation.getKeys()]; const key = keys.find((_, i) => i === order); if (key) { const valueAsArray = this.getValueAsArray(animation.dataType, key.value); return { frame: key?.frame, value: valueAsArray[coordinate] }; } else { return undefined; } } /** * Keyframe Manipulation * This section handles events from SvgDraggableArea. * @param id selected keyframe id * @param multiselect if we are multiselecting keyframes */ selectKeyframe = (id: string, multiselect: boolean) => { let frameValue: IActionableKeyFrame | undefined; const selectedKeyframe = this.state.svgKeyframes?.find((kf) => kf.id === id); const isKeyFrameSelected = selectedKeyframe?.selected; const hasCollinearPoints = this.hasCollinearPoints(selectedKeyframe); if (!multiselect) { frameValue = this.getKeyframeValueFromAnimation(id); this.deselectKeyframes(); } else { frameValue = { frame: undefined, value: undefined }; } if (isKeyFrameSelected) { frameValue = { frame: undefined, value: undefined }; } const updatedKeyframes = this.state.svgKeyframes?.map((kf) => { if (kf.id === id) { kf.selected = !isKeyFrameSelected; } return kf; }); let maxFrame = undefined; let minFrame = undefined; if (frameValue && typeof frameValue.frame === "number") { let { prev, next } = this.getPreviousAndNextKeyframe(frameValue.frame); maxFrame = next; minFrame = prev; } this.setState({ svgKeyframes: updatedKeyframes, actionableKeyframe: frameValue ?? this.state.actionableKeyframe, maxFrame: maxFrame, minFrame: minFrame, isBrokenMode: !hasCollinearPoints, }); }; /** * Determine if two control points are collinear (flat tangent) * @param kf keyframe point */ hasCollinearPoints = (kf: IKeyframeSvgPoint | undefined) => { const left = kf?.leftControlPoint; const right = kf?.rightControlPoint; if (left === undefined || right === undefined || left === null || right === null) { return false; } else { if (left.y === right.y) { return true; } else { return false; } } }; /** * Returns the previous and next keyframe from a selected frame. * @param frame index of keyframe */ getPreviousAndNextKeyframe = (frame: number) => { let prev, next = undefined; const animation = this.state.selected; if (animation) { const keys = animation.getKeys(); if (keys) { const index = keys.findIndex((x) => x.frame === frame); prev = keys[index - 1] && keys[index - 1].frame + 1; next = keys[index + 1] && keys[index + 1].frame - 1; } } return { prev, next }; }; /** * Selects a keyframe in animation based on its Id * @param id keyframe id * @param actionableKeyframe selected keyframe */ selectKeyframeFromId = (id: string, actionableKeyframe: IActionableKeyFrame) => { this.deselectKeyframes(); const updatedKeyframes = this.state.svgKeyframes?.map((kf) => { if (kf.id === id) { kf.selected = true; } return kf; }); let { prev, next } = this.getPreviousAndNextKeyframe(actionableKeyframe.frame as number); this.setState({ svgKeyframes: updatedKeyframes, actionableKeyframe: actionableKeyframe ?? this.state.actionableKeyframe, maxFrame: next, minFrame: prev, }); }; /** * Resets the current selected keyframe as an updatable pairs by Graph Control Bar */ resetActionableKeyframe = () => { this.setState({ actionableKeyframe: { frame: undefined, value: undefined }, maxFrame: undefined, minFrame: undefined, }); }; /** * Sets the selected control point. * @param type left or right control point * @param id id of selected svg keyframe */ selectedControlPoint = (type: string, id: string) => { const controlPoint = this.state.svgKeyframes?.find((x) => x.id === id); if (controlPoint) { let isSelected; if (type === "left") { isSelected = controlPoint.isLeftActive; controlPoint.isLeftActive = !isSelected; controlPoint.isRightActive = false; } if (type === "right") { isSelected = controlPoint.isRightActive; controlPoint.isRightActive = !isSelected; controlPoint.isLeftActive = false; } } let updatedKeyframes = this.state.svgKeyframes?.map((kf) => { if (kf.id === id) { kf === controlPoint; } return kf; }); this.setState({ svgKeyframes: updatedKeyframes }); }; /** * Sets the selected control point. */ deselectKeyframes = () => { let updatedKeyframes = this.state.svgKeyframes?.map((kf) => { kf.isLeftActive = false; kf.isRightActive = false; kf.selected = false; return kf; }); this.setState({ svgKeyframes: updatedKeyframes, actionableKeyframe: { frame: undefined, value: undefined }, maxFrame: undefined, minFrame: undefined, }); }; /** * Update the Animation Key values based on its type * @param dataType Type of animated property * @param newValue New value of animated property * @param coordinate The selected property coordinate to animate (i.e. x, y, z) */ updateValuePerCoordinate( dataType: number, value: number | Vector2 | Vector3 | Color3 | Color4 | Size | Quaternion, newValue: number, coordinate?: number ) { if (dataType === Animation.ANIMATIONTYPE_FLOAT) { value = newValue; } if (dataType === Animation.ANIMATIONTYPE_VECTOR2) { switch (coordinate) { case SelectedCoordinate.x: (value as Vector2).x = newValue; break; case SelectedCoordinate.y: (value as Vector2).y = newValue; break; } } if (dataType === Animation.ANIMATIONTYPE_VECTOR3) { switch (coordinate) { case SelectedCoordinate.x: (value as Vector3).x = newValue; break; case SelectedCoordinate.y: (value as Vector3).y = newValue; break; case SelectedCoordinate.z: (value as Vector3).z = newValue; break; } } if (dataType === Animation.ANIMATIONTYPE_QUATERNION) { switch (coordinate) { case SelectedCoordinate.x: (value as Quaternion).x = newValue; break; case SelectedCoordinate.y: (value as Quaternion).y = newValue; break; case SelectedCoordinate.z: (value as Quaternion).z = newValue; break; case SelectedCoordinate.w: (value as Quaternion).w = newValue; break; } } if (dataType === Animation.ANIMATIONTYPE_COLOR3) { switch (coordinate) { case SelectedCoordinate.r: (value as Color3).r = newValue; break; case SelectedCoordinate.g: (value as Color3).g = newValue; break; case SelectedCoordinate.b: (value as Color3).b = newValue; break; } } if (dataType === Animation.ANIMATIONTYPE_COLOR4) { switch (coordinate) { case SelectedCoordinate.r: (value as Color4).r = newValue; break; case SelectedCoordinate.g: (value as Color4).g = newValue; break; case SelectedCoordinate.b: (value as Color4).b = newValue; break; case SelectedCoordinate.a: (value as Color4).a = newValue; break; } } if (dataType === Animation.ANIMATIONTYPE_SIZE) { switch (coordinate) { case SelectedCoordinate.width: (value as Size).width = newValue; break; case SelectedCoordinate.g: (value as Size).height = newValue; break; } } return value; } /** * Animation should always have a keyframe at Frame Zero * @param keys Animation key collection */ forceFrameZeroToExist(keys: IAnimationKey[]) { const zeroFrame = keys.find((x) => Math.abs(x.frame) === 0); if (zeroFrame === undefined) { const prevToZero = keys.filter((x) => Math.sign(x.frame) === -1).sort((a, b) => b.frame - a.frame); let value; if (prevToZero.length !== 0) { value = prevToZero[0].value; } else { value = 1; } const frame: IAnimationKey = { frame: 0, value }; keys.push(frame); keys.sort((a, b) => a.frame - b.frame); } } /** * Renders SVG points with dragging of the curve * @param updatedSvgKeyFrame selected keyframe to update * @param id curve id where the keyframe is localted */ renderPoints = (updatedSvgKeyFrame: IKeyframeSvgPoint, id: string) => { let animation = this.state.selected as Animation; const { order: index, coordinate } = this.decodeCurveId(id); let keys = [...animation.getKeys()]; let newFrame = 0; if (updatedSvgKeyFrame.keyframePoint.x !== 0) { if (updatedSvgKeyFrame.keyframePoint.x > 0 && updatedSvgKeyFrame.keyframePoint.x < 1) { newFrame = 0; } else { newFrame = Math.round(updatedSvgKeyFrame.keyframePoint.x / this._pixelFrameUnit); } } if (newFrame > keys[index].frame) { if (index === keys.length - 1) { keys[index].frame = newFrame; } else { const nextKf = keys[index + 1]; if (nextKf) { if (nextKf.frame <= newFrame) { keys[index].frame = keys[index].frame; } else { keys[index].frame = newFrame; } } } } if (newFrame < keys[index].frame) { if (index === 0) { keys[index].frame = newFrame; } else { const prevKf = keys[index - 1]; if (prevKf) { if (prevKf.frame >= newFrame) { keys[index].frame = keys[index].frame; } else { keys[index].frame = newFrame; } } } } let updatedValue = ((this._heightScale - updatedSvgKeyFrame.keyframePoint.y) / this._heightScale) * this._scaleFactor; const updatedValueInCoordinate = this.updateValuePerCoordinate( animation.dataType, keys[index].value, updatedValue, coordinate ); keys[index].value = updatedValueInCoordinate; this.updateLeftControlPoint(updatedSvgKeyFrame, keys[index], animation.dataType, coordinate); this.updateRightControlPoint(updatedSvgKeyFrame, keys[index], animation.dataType, coordinate); this.forceFrameZeroToExist(keys); animation.setKeys(keys); let { prev, next } = this.getPreviousAndNextKeyframe(newFrame); this.setState({ actionableKeyframe: { frame: newFrame, value: updatedValueInCoordinate }, maxFrame: next, minFrame: prev, }); this.selectAnimation(animation, coordinate); }; /** * Updates the left control point on render points * @param updatedSvgKeyFrame selected svg keyframe * @param key animation key to update * @param dataType type of animation * @param coordinate coordinate to change its value (x, y, or z) */ updateLeftControlPoint( updatedSvgKeyFrame: IKeyframeSvgPoint, key: IAnimationKey, dataType: number, coordinate: number ) { if (updatedSvgKeyFrame.isLeftActive) { if (updatedSvgKeyFrame.leftControlPoint !== null) { // Rotate Control Points // Get the previous svgKeyframe and measure distance between these two points let distanceWithPreviousKeyframe = this.getControlPointWeight(updatedSvgKeyFrame); let distanceAmplitudeOfX = updatedSvgKeyFrame.leftControlPoint.x - distanceWithPreviousKeyframe; let slope = (updatedSvgKeyFrame.leftControlPoint.y - updatedSvgKeyFrame.keyframePoint.y) / (updatedSvgKeyFrame.leftControlPoint.x - updatedSvgKeyFrame.keyframePoint.x); let newValueOfY = (distanceAmplitudeOfX - updatedSvgKeyFrame.leftControlPoint.x) * slope + updatedSvgKeyFrame.keyframePoint.y; let updatedValue = ((newValueOfY - updatedSvgKeyFrame.keyframePoint.y) * this._scaleFactor) / this._heightScale; if (updatedValue > -100 && updatedValue < 100) { key.inTangent = slope; if (!this.state.isBrokenMode) { if (updatedSvgKeyFrame.rightControlPoint !== null) { // get angle between control points and keep angle to allow broken control points feature key.outTangent = key.inTangent * -1; } } } } } } /** * Updates the right control point on render points * @param updatedSvgKeyFrame selected svg keyframe * @param key animation key to update * @param dataType type of animation * @param coordinate coordinate to change its value (x, y, or z) */ updateRightControlPoint( updatedSvgKeyFrame: IKeyframeSvgPoint, key: IAnimationKey, dataType: number, coordinate: number ) { if (updatedSvgKeyFrame.isRightActive) { if (updatedSvgKeyFrame.rightControlPoint !== null) { // Get the next svgKeyframe and measure distance between these two points let distanceWithNextKeyframe = this.getControlPointWeight(updatedSvgKeyFrame); let distanceAmplitudeOfX = updatedSvgKeyFrame.rightControlPoint.x + distanceWithNextKeyframe; let slope = (updatedSvgKeyFrame.rightControlPoint.y - updatedSvgKeyFrame.keyframePoint.y) / (updatedSvgKeyFrame.rightControlPoint.x - updatedSvgKeyFrame.keyframePoint.x); let newValueOfY = (distanceAmplitudeOfX - updatedSvgKeyFrame.rightControlPoint.x) * slope + updatedSvgKeyFrame.keyframePoint.y; let updatedValue = ((newValueOfY - updatedSvgKeyFrame.keyframePoint.y) * this._scaleFactor) / this._heightScale; if (updatedValue > -100 && updatedValue < 100) { key.outTangent = slope * -1; if (!this.state.isBrokenMode) { if (updatedSvgKeyFrame.leftControlPoint !== null) { // get angle between control points and keep angle to allow broken control points feature key.inTangent = key.outTangent * -1; } } } } } } /** * Get the current Control Point weight (how far the X value is multiplied) * @param updatedSvgKeyFrame svg keyframe from which to calculate the distance to control point */ getControlPointWeight(updatedSvgKeyFrame: IKeyframeSvgPoint) { let distanceWithPreviousKeyframe = this.state.canvasWidthScale / 4; if (this.state.svgKeyframes) { let indexOfKeyframe = this.state.svgKeyframes.indexOf(updatedSvgKeyFrame); let previousKeyframe = this.state.svgKeyframes[indexOfKeyframe - 1]; if (previousKeyframe?.keyframePoint) { distanceWithPreviousKeyframe = Vector2.Distance(updatedSvgKeyFrame.keyframePoint, previousKeyframe.keyframePoint) / 2; } } let distanceWithNextKeyframe = this.state.canvasWidthScale / 4; if (this.state.svgKeyframes) { let indexOfKeyframe = this.state.svgKeyframes.indexOf(updatedSvgKeyFrame); let nextKeyframe = this.state.svgKeyframes[indexOfKeyframe + 1]; if (nextKeyframe?.keyframePoint) { distanceWithNextKeyframe = Vector2.Distance(nextKeyframe.keyframePoint, updatedSvgKeyFrame.keyframePoint) / 2; } } if (distanceWithPreviousKeyframe < distanceWithNextKeyframe) { return distanceWithPreviousKeyframe; } else { return distanceWithNextKeyframe; } } /** * Handles a Frame selection change * @event event input event to change frame value */ handleFrameChange = (event: React.ChangeEvent) => { event.preventDefault(); let frame; let maxFrame = undefined; let minFrame = undefined; if (event.target.value === "") { frame = ""; } else { frame = parseInt(event.target.value); const { prev, next } = this.getPreviousAndNextKeyframe(frame); maxFrame = next; minFrame = prev; } this.setState({ actionableKeyframe: { frame: frame, value: this.state.actionableKeyframe.value, }, maxFrame, minFrame, }); }; /** * Handles how a value change on a selected frame * @event event input event to change the value of the keyframe */ handleValueChange = (event: React.ChangeEvent) => { event.preventDefault(); let value; if (event.target.value !== undefined) { if (event.target.value !== "") { value = parseFloat(event.target.value); } else { value = ""; } this.setState({ actionableKeyframe: { frame: this.state.actionableKeyframe.frame, value: value, }, maxFrame: undefined, minFrame: undefined, }); } }; /** * Set the Keyframe from input control in Graph Control Bar * @param actionableKeyframe selected keyframe */ setKeyframeValueFromInput = (actionableKeyframe: IActionableKeyFrame) => { this.setState( { actionableKeyframe, }, this.setKeyframeValue ); }; /** * Sets the SVG Keyframe value * Finds the selected svg keyframe and passes the correct value to the correct animated keyframe. */ setKeyframeValue = () => { if ( this.state.actionableKeyframe.frame !== "" && this.state.actionableKeyframe.frame !== undefined && this.state.actionableKeyframe.value !== "" && this.state.actionableKeyframe.value !== undefined ) { if (this.state.selected !== null) { let currentSelected = this.state.svgKeyframes?.find((kf) => kf.selected); if (currentSelected) { let { order, coordinate } = this.decodeCurveId(currentSelected.id); let animation = this.state.selected; let keys = animation.getKeys(); let isKeyframe = keys.find((_, i) => i === order); if (isKeyframe) { let updatedKeys = keys.map((k, i) => { if (i === order) { k.frame = this.state.actionableKeyframe.frame as number; const currentValue = this.getValueAsArray(animation.dataType, k.value); currentValue[coordinate] = this.state.actionableKeyframe.value; k.value = currentValue.length === 1 ? currentValue[0] : currentValue; } return k; }); this.forceFrameZeroToExist(updatedKeys); this.state.selected.setKeys(updatedKeys); this.selectAnimation(animation); this.setCanvasPosition({ frame: this.state.actionableKeyframe.frame as number, value: this.state.actionableKeyframe.value, }); const { prev, next } = this.getPreviousAndNextKeyframe( this.state.actionableKeyframe.frame as number ); this.setState({ maxFrame: next, minFrame: prev }); } } } } }; /** * Set the flat tangent to the current selected control points. * get the selected control point and updates its tangent to flat the tangent */ setFlatTangent = () => { if (this.state.selected !== null) { const keyframes = this.state.svgKeyframes?.filter((kf) => kf.selected).map((k) => this.decodeCurveId(k.id)); const currentAnimation = this.state.selected; const keys = currentAnimation.getKeys(); if (this.state.isBrokenMode) { const keyframeWithControlPointSelected = this.state.svgKeyframes?.find((kf) => kf.selected); if (keyframeWithControlPointSelected) { keyframes?.forEach((k) => { const keyframe = keys[k.order]; if (keyframeWithControlPointSelected.isLeftActive) { keyframe.inTangent = this.returnZero(currentAnimation.dataType); } if (keyframeWithControlPointSelected.isRightActive) { keyframe.outTangent = this.returnZero(currentAnimation.dataType); } }); } } else { keyframes?.forEach((k) => { const keyframe = keys[k.order]; keyframe.inTangent = this.returnZero(currentAnimation.dataType); keyframe.outTangent = this.returnZero(currentAnimation.dataType); }); } currentAnimation.setKeys(keys); this.selectAnimation(currentAnimation, this.state.selectedCoordinate); } }; /** * Sets Broken mode of lines * Broken mode enables control points not to be connected on drag */ setBrokenMode = () => { if (this.state.selected !== null) { let animation = this.state.selected; this.setState({ isBrokenMode: !this.state.isBrokenMode }, () => this.selectAnimation(animation, this.state.selectedCoordinate) ); } }; /** * Sets a control point to be a linear interpolation with its Keyframe * If left or right control point is selected, sets the Tangent value to match the next keyframe and become linear */ setLerpToActiveControlPoint = () => { const animation = this.state.selected; if (this.state.svgKeyframes && animation) { const keys = animation.getKeys(); const selectedKeyframe = this.state.svgKeyframes.find( (keyframe: IKeyframeSvgPoint) => keyframe.selected && (keyframe.isLeftActive || keyframe.isRightActive) ); if (selectedKeyframe !== null && selectedKeyframe) { const { order, coordinate } = this.decodeCurveId(selectedKeyframe.id); const key = keys[order]; if (selectedKeyframe.isLeftActive && selectedKeyframe.leftControlPoint !== null) { const start = new Vector2(key.frame, key.value); const prev = new Vector2(keys[order - 1].frame, keys[order - 1].value); let slope = (start.y - prev.y) / (start.x - prev.x); key.inTangent = slope * -1; } else if (selectedKeyframe.isRightActive && selectedKeyframe.rightControlPoint !== null) { const start = new Vector2(key.frame, key.value); const next = new Vector2(keys[order + 1].frame, keys[order + 1].value); let slope = (next.y - start.y) / (next.x - start.x); key.outTangent = slope; } this.setState({ isBrokenMode: true }, () => { this.selectAnimation(animation, coordinate); }); } } }; /** * Adds a new keyframe to the curve on canvas click */ addKeyframeClick = () => { if (this.state.selected !== null) { let currentAnimation = this.state.selected; let keys = currentAnimation.getKeys(); let x = this.state.currentFrame; let existValue = keys.find((k) => k.frame === x); if (existValue === undefined) { let y = this.state.actionableKeyframe.value ?? 1; let arrayValue: any = []; let emptyValue = this.returnZero(currentAnimation.dataType); if (emptyValue) { arrayValue = this.getValueAsArray(currentAnimation.dataType, emptyValue); } // calculate point between prevkeyframe and nextkeyframe. const previousKFs = keys.filter((kf) => kf.frame < x); const nextKFs = keys.filter((kf) => kf.frame > x); const prev = previousKFs.slice(-1)[0]; const next = nextKFs[0]; if (prev === undefined && next) { y = next.value; } if (prev && next === undefined) { y = prev.value; } if (prev && next) { const value1 = new Vector2(prev.frame, prev.value); const tangent1 = new Vector2(prev.outTangent, prev.outTangent); const value2 = new Vector2(next.frame, next.value); const tangent2 = new Vector2(next.inTangent, next.inTangent); const amount = (x - prev.frame) / (next.frame - prev.frame); const newV = Vector2.Hermite(value1, tangent1, value2, tangent2, amount); y = newV.y; } arrayValue[this.state.selectedCoordinate] = y; let actualValue = this.setValueAsType(currentAnimation.dataType, arrayValue); const recentlyCreated = { frame: x, value: actualValue, inTangent: this.state.isFlatTangentMode ? 0 : 0, // check if flat mode can be turned off outTangent: this.state.isFlatTangentMode ? 0 : 0, // check if flat mode can be turned off }; keys.push(recentlyCreated); keys.sort((a, b) => a.frame - b.frame); const newIndex = keys.findIndex((kf) => kf.frame === x); const id = `${currentAnimation.name}_${currentAnimation.targetProperty}_${this.state.selectedCoordinate}`; const curvedId = this.encodeCurveId(id, newIndex); this.setState({ lastKeyframeCreated: curvedId }); this.forceFrameZeroToExist(keys); currentAnimation.setKeys(keys); this.selectAnimation(currentAnimation, this.state.selectedCoordinate); } } }; /** * Remove keyframe on click */ removeKeyframeClick = () => { if (this.state.selected !== null) { let currentAnimation = this.state.selected; if (currentAnimation.dataType === Animation.ANIMATIONTYPE_FLOAT) { let keys = currentAnimation.getKeys(); let x = this.state.currentFrame; let filteredKeys = keys.filter((kf) => kf.frame !== x); currentAnimation.setKeys(filteredKeys); this.selectAnimation(currentAnimation, this.state.selectedCoordinate); } } }; /** * Remove the selected keyframes * @param points the selected keyframes to remove */ removeKeyframes = (points: IKeyframeSvgPoint[]) => { if (this.state.selected !== null) { let currentAnimation = this.state.selected; const indexesToRemove = points.map((p) => { return { index: parseInt(p.id.split("_")[3]), coordinate: parseInt(p.id.split("_")[2]), }; }); if (currentAnimation.dataType === Animation.ANIMATIONTYPE_FLOAT) { let keys = currentAnimation.getKeys(); let filteredKeys = keys.filter((_, i) => { return !indexesToRemove.find((x) => x.index === i); }); currentAnimation.setKeys(filteredKeys); this.deselectKeyframes(); this.selectAnimation(currentAnimation, this.state.selectedCoordinate); } } }; /** * Adds a keyframe * @event event Mouse click event to generate new keyframe */ addKeyFrame(event: React.MouseEvent) { event.preventDefault(); if (this.state.selected !== null) { var svg = event.target as SVGSVGElement; var pt = svg.createSVGPoint(); pt.x = event.clientX; pt.y = event.clientY; var inverse = svg.getScreenCTM()?.inverse(); var cursorpt = pt.matrixTransform(inverse); var currentAnimation = this.state.selected; var keys = currentAnimation.getKeys(); var height = 100; var middle = height / 2; var keyValue; if (cursorpt.y < middle) { keyValue = 1 + (100 / cursorpt.y) * 0.1; } if (cursorpt.y > middle) { keyValue = 1 - (100 / cursorpt.y) * 0.1; } keys.push({ frame: cursorpt.x, value: keyValue }); currentAnimation.setKeys(keys); this.selectAnimation(currentAnimation); } } /** * Curve Rendering Functions * This section handles how to render curves. * @param point point in canvas * @param index index of svg */ setKeyframePointLinear(point: Vector2, index: number) { let svgKeyframe = { keyframePoint: point, rightControlPoint: null, leftControlPoint: null, id: index.toString(), selected: false, isLeftActive: false, isRightActive: false, }; this._svgKeyframes.push(svgKeyframe); } /** * Flats the tangents of the selected keyframes * @param keyframes Selected keyframes to flat its tangents * @param dataType Type of animated property */ flatTangents(keyframes: IAnimationKey[], dataType: number) { let flattened; if (this.state && this.state.isFlatTangentMode) { flattened = keyframes.map((kf) => { if (kf.inTangent !== undefined) { kf.inTangent = this.returnZero(dataType); } if (kf.outTangent !== undefined) { kf.outTangent = this.returnZero(dataType); } return kf; }); } else { flattened = keyframes; } this.setState({ isFlatTangentMode: false }); return flattened; } /** * Return a Keyframe zero value depending on Type * @param dataType Type of animated property */ returnZero(dataType: number) { switch (dataType) { case Animation.ANIMATIONTYPE_FLOAT: return 0; case Animation.ANIMATIONTYPE_VECTOR3: return Vector3.Zero(); case Animation.ANIMATIONTYPE_VECTOR2: return Vector2.Zero(); case Animation.ANIMATIONTYPE_QUATERNION: return Quaternion.Zero(); case Animation.ANIMATIONTYPE_COLOR3: return new Color3(0, 0, 0); case Animation.ANIMATIONTYPE_COLOR4: return new Color4(0, 0, 0, 0); case Animation.ANIMATIONTYPE_SIZE: return new Size(0, 0); default: return 0; } } /** * Return the keyframe value as an array depending on type * @param valueType type of animated property * @param value the value of the property */ getValueAsArray(valueType: number, value: number | Vector2 | Vector3 | Color3 | Color4 | Size | Quaternion) { switch (valueType) { case Animation.ANIMATIONTYPE_FLOAT: return [value as number]; case Animation.ANIMATIONTYPE_VECTOR3: return (value as Vector3).asArray(); case Animation.ANIMATIONTYPE_VECTOR2: return (value as Vector2).asArray(); case Animation.ANIMATIONTYPE_QUATERNION: return (value as Quaternion).asArray(); case Animation.ANIMATIONTYPE_COLOR3: return (value as Color3).asArray(); case Animation.ANIMATIONTYPE_COLOR4: return (value as Color4).asArray(); case Animation.ANIMATIONTYPE_SIZE: return [(value as Size).width, (value as Size).height]; default: return []; } } /** * Sets the keyframe value as an array depending on type * @param valueType type of animated property * @param arrayValue array to place the correct value */ setValueAsType(valueType: number, arrayValue: number[]) { switch (valueType) { case Animation.ANIMATIONTYPE_FLOAT: return arrayValue[0]; case Animation.ANIMATIONTYPE_VECTOR3: return new Vector3(arrayValue[0], arrayValue[1], arrayValue[2]); case Animation.ANIMATIONTYPE_VECTOR2: return new Vector2(arrayValue[0], arrayValue[1]); case Animation.ANIMATIONTYPE_QUATERNION: return new Quaternion(arrayValue[0], arrayValue[1], arrayValue[2], arrayValue[3]); case Animation.ANIMATIONTYPE_COLOR3: return new Color3(arrayValue[0], arrayValue[1], arrayValue[2]); case Animation.ANIMATIONTYPE_COLOR4: return new Color4(arrayValue[0], arrayValue[1], arrayValue[2], arrayValue[3]); case Animation.ANIMATIONTYPE_SIZE: return new Size(arrayValue[0], arrayValue[1]); default: return arrayValue[0]; } } /** * Returns the SVG Path Data to render the curve * @param animation The animation object from where to get its keyframes */ getPathData(animation: Animation | null) { if (animation === null) { return undefined; } // Get the animation keyframes var keyframes = animation.getKeys(); // If no keyframes return undefined if (keyframes === undefined || keyframes.length === 0) { return undefined; } else { // Get the animation properties const { easingMode, easingType, usesTangents, valueType, highestFrame, name, targetProperty, } = this.getAnimationData(animation); // Set the initial point of the curve const startKey = keyframes[0]; // Get the middle of the screen let middle = this._heightScale / this._scaleFactor; // Collection of curves if animation has coodirnates (ie. x, y, z) let collection: ICurveData[] = []; // The basic colors for curves const colors = ["red", "green", "blue", "white", "#7a4ece"]; // Get the initial value of the first keyframe const startValue = this.getValueAsArray(valueType, startKey.value); // Iterate thru coordinates to create curves (x, y, z) for (var d = 0; d < startValue.length; d++) { const id = `${name}_${targetProperty}_${d}`; const curveColor = valueType === Animation.ANIMATIONTYPE_FLOAT ? colors[4] : colors[d]; // START OF LINE/CURVE let data: string | undefined = `M${startKey.frame * this._pixelFrameUnit}, ${ this._heightScale - startValue[d] * middle }`; // if (this.state) { if (usesTangents) { // Generate the svg curve path data = this.curvePathWithTangents(keyframes, data, middle, valueType, d, id); } else { // Perform other calculation if there are no Tangents in the animation. // This would need updates if we support out of the box easings in curve editor if (easingType !== undefined && easingMode !== undefined) { let easingFunction = animation.getEasingFunction(); data = this.curvePath(keyframes, data, middle, easingFunction as EasingFunction); } else { if (this.state !== undefined) { data = this.curvePathWithoutTangents(keyframes, data, middle, valueType, d, id); } } } } collection.push({ pathData: data, pathLength: highestFrame, domCurve: React.createRef(), color: curveColor, id: id, }); } // return the curves in animation return collection; } } /** * Gets the selected animation properties * @param animation Animation */ getAnimationData(animation: Animation) { // General Props let loopMode = animation.loopMode; let name = animation.name; let blendingSpeed = animation.blendingSpeed; let targetProperty = animation.targetProperty; let targetPropertyPath = animation.targetPropertyPath; let framesPerSecond = animation.framePerSecond; let highestFrame = animation.getHighestFrame(); let usesTangents = animation.getKeys().find((kf) => kf.hasOwnProperty("inTangent") || kf.hasOwnProperty("outTangent")) !== undefined ? true : false; let valueType = animation.dataType; let easingType, easingMode; let easingFunction: EasingFunction = animation.getEasingFunction() as EasingFunction; if (easingFunction === undefined) { easingType = undefined; easingMode = undefined; } else { easingType = easingFunction.constructor.name; easingMode = easingFunction.getEasingMode(); } return { loopMode, name, blendingSpeed, targetPropertyPath, targetProperty, framesPerSecond, highestFrame, usesTangents, easingType, easingMode, valueType, }; } /** * Calculate the correct tangents to be linear * @param keyframes the animation keyframes */ calculateLinearTangents(keyframes: IAnimationKey[]) { const updatedKeyframes: IAnimationKey[] = keyframes.map((kf, i) => { if (keyframes[i + 1] !== undefined) { const start = new Vector2(keyframes[i].frame, keyframes[i].value); const next = new Vector2(keyframes[i + 1].frame, keyframes[i + 1].value); let slope = (next.y - start.y) / (next.x - start.x); kf.outTangent = slope; } if (keyframes[i - 1] !== undefined) { const start = new Vector2(keyframes[i].frame, keyframes[i].value); const prev = new Vector2(keyframes[i - 1].frame, keyframes[i - 1].value); let slope = (prev.y - start.y) / (prev.x - start.x); kf.inTangent = slope * -1; } if (i === keyframes.length - 1) { kf.outTangent = null; } return kf; }); return updatedKeyframes; } /** * Calculates the proper linear tangents if there is no tangents defined * Before calculation of svg path we need to calculate the proper tangents * @param keyframes the animation keyframes * @param data initial svg path * @param middle the middle of the canvas to draw the curve * @param type type of animation * @param coordinate (x, y, z) value * @param animationName The animation name to generate the curve id */ curvePathWithoutTangents( keyframes: IAnimationKey[], data: string, middle: number, type: number, coordinate: number, animationName: string ) { const updatedKeyframes = this.calculateLinearTangents(keyframes); return this.curvePathWithTangents(updatedKeyframes, data, middle, type, coordinate, animationName); } /** * Calculates the curve data and control points for animation * @param keyframes the animation keyframes * @param data initial svg path * @param middle the middle of the canvas to draw the curve * @param type type of animation * @param coordinate (x, y, z) value * @param animationName The animation name to generate the curve id */ curvePathWithTangents( keyframes: IAnimationKey[], data: string, middle: number, type: number, coordinate: number, animationName: string ) { keyframes.forEach((key, i) => { // Create a unique id for curve const curveId = this.encodeCurveId(animationName, i); // identify type of value and split... const keyframe_valueAsArray = this.getValueAsArray(type, key.value)[coordinate]; let svgKeyframe; let outTangent; let inTangent; let defaultWeight = this.state.canvasWidthScale / 2; // For inTangent // has prev frame? let weightIn = 0; if (keyframes[i - 1] !== undefined) { // calculate the correct weight/distance to contorl point let prevIn = new Vector2(keyframes[i - 1].frame, keyframes[i - 1].value); let currIn = new Vector2(key.frame, key.value); weightIn = (Vector2.Distance(prevIn, currIn) / 2) * this._pixelFrameUnit; } // For outTangent // has next frame? let weightOut = 0; if (keyframes[i + 1] !== undefined) { // calculate the correct weight/distance to contorl point let prevOut = new Vector2(keyframes[i + 1].frame, keyframes[i + 1].value); let currOut = new Vector2(key.frame, key.value); weightOut = (Vector2.Distance(prevOut, currOut) / 2) * this._pixelFrameUnit; } if (weightIn !== 0 && weightOut !== 0) { if (weightIn < weightOut) { defaultWeight = weightIn > defaultWeight ? defaultWeight : weightIn; } else { defaultWeight = weightOut > defaultWeight ? defaultWeight : weightOut; } } if (weightIn === 0 && weightOut !== 0) { defaultWeight = weightOut > defaultWeight ? defaultWeight : weightOut; } if (weightIn !== 0 && weightOut === 0) { defaultWeight = weightIn > defaultWeight ? defaultWeight : weightIn; } let defaultTangent: number | null = null; if (i !== 0 || i !== keyframes.length - 1) { defaultTangent = null; } // Calculate the control points from the specified tangent values var inT = key.inTangent === null || key.inTangent === undefined ? defaultTangent : this.getValueAsArray(type, key.inTangent)[coordinate]; var outT = key.outTangent === null || key.outTangent === undefined ? defaultTangent : this.getValueAsArray(type, key.outTangent)[coordinate]; defaultWeight = 1 * this._pixelFrameUnit; // update based on control points if (inT !== null) { let valueInY = inT + keyframe_valueAsArray; let valueIn = this._heightScale - valueInY * middle; inTangent = new Vector2(key.frame * this._pixelFrameUnit - defaultWeight, valueIn); } else { inTangent = null; } if (outT !== null) { let valueOutY = outT + keyframe_valueAsArray; let valueOut = this._heightScale - valueOutY * middle; outTangent = new Vector2(key.frame * this._pixelFrameUnit + defaultWeight, valueOut); } else { outTangent = null; } // Define each point of the cuvre and the value on the SVG Path. if (i === 0) { svgKeyframe = { keyframePoint: new Vector2( key.frame * this._pixelFrameUnit, this._heightScale - keyframe_valueAsArray * middle ), rightControlPoint: outTangent, leftControlPoint: null, id: curveId, selected: false, isLeftActive: false, isRightActive: false, }; if (outTangent !== null) { data += ` C${outTangent.x} ${outTangent.y} `; } else { data += ` C${svgKeyframe.keyframePoint.x} ${svgKeyframe.keyframePoint.y} `; } } else { svgKeyframe = { keyframePoint: new Vector2( key.frame * this._pixelFrameUnit, this._heightScale - keyframe_valueAsArray * middle ), rightControlPoint: outTangent, leftControlPoint: inTangent, id: curveId, selected: false, isLeftActive: false, isRightActive: false, }; if (outTangent !== null && inTangent !== null) { data += ` ${inTangent.x} ${inTangent.y} ${svgKeyframe.keyframePoint.x} ${svgKeyframe.keyframePoint.y} C${outTangent.x} ${outTangent.y}`; } if (outTangent === null && inTangent !== null) { data += ` ${inTangent.x} ${inTangent.y} ${svgKeyframe.keyframePoint.x} ${svgKeyframe.keyframePoint.y} C${svgKeyframe.keyframePoint.x} ${svgKeyframe.keyframePoint.y}`; } if (inTangent === null && outTangent !== null) { const prev = this._svgKeyframes[i - 1]; data += ` ${prev.keyframePoint.x} ${prev.keyframePoint.y} ${svgKeyframe.keyframePoint.x} ${svgKeyframe.keyframePoint.y} C${outTangent.x} ${outTangent.y}`; } if (inTangent === null && outTangent === null) { const prev = this._svgKeyframes[i - 1]; data += ` ${prev.keyframePoint.x} ${prev.keyframePoint.y} ${svgKeyframe.keyframePoint.x} ${svgKeyframe.keyframePoint.y} C${svgKeyframe.keyframePoint.x} ${svgKeyframe.keyframePoint.y}`; } } if (this.state) { let prev = this.state.svgKeyframes?.find((kf) => kf.id === curveId); if (prev) { svgKeyframe.isLeftActive = prev?.isLeftActive; svgKeyframe.isRightActive = prev?.isRightActive; svgKeyframe.selected = prev?.selected; } } this._svgKeyframes.push(svgKeyframe); }, this); // Clean the last curve not to begin a new curve const lastCurveEnd = data.lastIndexOf("C"); const cleanedData = data.substring(0, lastCurveEnd); return cleanedData; } /** * Calculates a curve path from predefined easing function * @param keyframes animation keyframes * @param data the initial svg path * @param middle the center of the canvas * @param easingFunction the easing function to calculate the curve from (easing function should generate the correct curve) */ curvePath(keyframes: IAnimationKey[], data: string, middle: number, easingFunction: EasingFunction) { // This will get 1/4 and 3/4 of points in eased curve const u = 0.25; const v = 0.75; keyframes.forEach((key, i) => { // identify type of value and split... // Gets previous initial point of curve segment var pointA = new Vector2(0, 0); if (i === 0) { pointA.x = key.frame; pointA.y = this._heightScale - key.value * middle; this.setKeyframePoint([pointA], i, keyframes.length); } else { pointA.x = keyframes[i - 1].frame; pointA.y = this._heightScale - keyframes[i - 1].value * middle; // Gets the end point of this curve segment var pointB = new Vector2(key.frame, this._heightScale - key.value * middle); // Get easing value of percentage to get the bezier control points below let du = easingFunction.easeInCore(u); // What to do here, when user edits the curve? Option 1: Modify the curve with the new control points as BezierEaseCurve(x,y,z,w) let dv = easingFunction.easeInCore(v); // Option 2: Create a easeInCore function and adapt it with the new control points values... needs more revision. // Direction of curve up/down let yInt25 = 0; if (pointB.y > pointA.y) { // if pointB.y > pointA.y = goes down yInt25 = (pointB.y - pointA.y) * du + pointA.y; } else if (pointB.y < pointA.y) { // if pointB.y < pointA.y = goes up yInt25 = pointA.y - (pointA.y - pointB.y) * du; } let yInt75 = 0; if (pointB.y > pointA.y) { yInt75 = (pointB.y - pointA.y) * dv + pointA.y; } else if (pointB.y < pointA.y) { yInt75 = pointA.y - (pointA.y - pointB.y) * dv; } // Intermediate points in curve let intermediatePoint25 = new Vector2((pointB.x - pointA.x) * u + pointA.x, yInt25); let intermediatePoint75 = new Vector2((pointB.x - pointA.x) * v + pointA.x, yInt75); // Gets the four control points of bezier curve let controlPoints = this.interpolateControlPoints( pointA, intermediatePoint25, u, intermediatePoint75, v, pointB ); if (controlPoints !== undefined) { this.setKeyframePoint(controlPoints, i, keyframes.length); data += ` C${controlPoints[1].x} ${controlPoints[1].y} ${controlPoints[2].x} ${controlPoints[2].y} ${controlPoints[3].x} ${controlPoints[3].y}`; } } }); return data; } /** * Sets the proper SVG Keyframe points * @param controlPoints array of points to generate the svg keyframe collection * @param index index of the animation * @param keyframesCount How many keyframes should we process */ setKeyframePoint(controlPoints: Vector2[], index: number, keyframesCount: number) { let svgKeyframe; if (index === 0) { svgKeyframe = { keyframePoint: controlPoints[0], rightControlPoint: null, leftControlPoint: null, id: index.toString(), selected: false, isLeftActive: false, isRightActive: false, }; } else { this._svgKeyframes[index - 1].rightControlPoint = controlPoints[1]; svgKeyframe = { keyframePoint: controlPoints[3], rightControlPoint: null, leftControlPoint: controlPoints[2], id: index.toString(), selected: false, isLeftActive: false, isRightActive: false, }; } this._svgKeyframes.push(svgKeyframe); } /** * Creates curve based on interpolated points * @param p0 starting point of curve * @param p1 first control point * @param u distance between p0 and p3 as percentage to affect p1 * @param p2 second control point * @param v distance between p0 and p3 as percentage to affect p2 * @param p3 finish point of curve */ interpolateControlPoints( p0: Vector2, p1: Vector2, u: number, p2: Vector2, v: number, p3: Vector2 ): Vector2[] | undefined { let a = 0.0; let b = 0.0; let c = 0.0; let d = 0.0; let det = 0.0; let q1: Vector2 = new Vector2(); let q2: Vector2 = new Vector2(); let controlA: Vector2 = p0; let controlB: Vector2 = new Vector2(); let controlC: Vector2 = new Vector2(); let controlD: Vector2 = p3; if (u <= 0.0 || u >= 1.0 || v <= 0.0 || v >= 1.0 || u >= v) { return undefined; } a = 3 * (1 - u) * (1 - u) * u; b = 3 * (1 - u) * u * u; c = 3 * (1 - v) * (1 - v) * v; d = 3 * (1 - v) * v * v; det = a * d - b * c; if (det == 0.0) { return undefined; } q1.x = p1.x - ((1 - u) * (1 - u) * (1 - u) * p0.x + u * u * u * p3.x); q1.y = p1.y - ((1 - u) * (1 - u) * (1 - u) * p0.y + u * u * u * p3.y); q2.x = p2.x - ((1 - v) * (1 - v) * (1 - v) * p0.x + v * v * v * p3.x); q2.y = p2.y - ((1 - v) * (1 - v) * (1 - v) * p0.y + v * v * v * p3.y); controlB.x = (d * q1.x - b * q2.x) / det; controlB.y = (d * q1.y - b * q2.y) / det; controlC.x = (-c * q1.x + a * q2.x) / det; controlC.y = (-c * q1.y + a * q2.y) / det; return [controlA, controlB, controlC, controlD]; } /** * Deselects the current animations */ deselectAnimation = () => { const animations = (this.props.entity as IAnimatable).animations; if (animations && animations.length === 0) { setTimeout(() => this.cleanCanvas(), 0); } this.cleanCanvas(); }; /** * Remove all curves from canvas */ cleanCanvas = () => { this.setState({ selected: null, svgKeyframes: [], selectedPathData: undefined, selectedCoordinate: 0, }); }; /** * Selects the animation and renders the curve * @param animation the animation to select and render as curve * @param coordinate the property coordinate (x, y, z) to represent as curve */ selectAnimation = (animation: Animation, coordinate?: SelectedCoordinate) => { this._svgKeyframes = []; let updatedPath; let filteredSvgKeys; let selectedCurve = 0; this.stopAnimation(); if (coordinate === undefined) { updatedPath = this.getPathData(animation); } else { let curves = this.getPathData(animation); updatedPath = []; filteredSvgKeys = this._svgKeyframes?.filter((curve) => { let id = parseInt(curve.id.split("_")[2]); if (id === coordinate) { return true; } else { return false; } }); curves?.map((curve) => { let id = parseInt(curve.id.split("_")[2]); if (id === coordinate) { updatedPath.push(curve); } }); selectedCurve = coordinate; } this.setState( { selected: animation, svgKeyframes: coordinate !== undefined ? filteredSvgKeys : this._svgKeyframes, selectedPathData: updatedPath, selectedCoordinate: selectedCurve, fps: animation.framePerSecond, }, this.postSelectionEvents ); }; /** * Set the state for the last selected keyframe */ postSelectionEvents = () => { if (this.state.lastKeyframeCreated !== null) { this.deselectKeyframes(); this.selectKeyframe(this.state.lastKeyframeCreated, false); this.setState({ lastKeyframeCreated: null }); } this.setMainAnimatable(); if (this.state.selected) { const lastKeyframe = this.state.selected.getHighestFrame(); const currentLimit = this.state.animationLimit; if (currentLimit < lastKeyframe) { this.changeAnimationLimit(lastKeyframe); } } }; /** * Set main animatable to play or pause the animation */ setMainAnimatable() { if (this.state.selected !== null) { let target = this.props.entity; if (this.props.entity instanceof TargetedAnimation) { target = this.props.entity.target; } this.props.scene.stopAllAnimations(); if (this._mainAnimatable?.target !== target) { const keys = this.state.selected.getKeys(); if (keys.length !== 0) { const firstFrame = keys[0].frame; const LastFrame = this.state.selected.getHighestFrame(); this._mainAnimatable = this.props.scene.beginAnimation( target, firstFrame, LastFrame, this.state.isLooping ); this._mainAnimatable.stop(); } } } } /** * Check if animation is playing */ isAnimationPlaying() { let target = this.props.entity; if (this.props.entity instanceof TargetedAnimation) { target = this.props.entity.target; } return this.props.scene.getAllAnimatablesByTarget(target).length > 0; } /** * Stops the current playing animation */ stopAnimation() { let target = this.props.entity; if (this.props.entity instanceof TargetedAnimation) { target = this.props.entity.target; } this._isPlaying = this.props.scene.getAllAnimatablesByTarget(target).length > 0; if (this._isPlaying) { this.props.playOrPause && this.props.playOrPause(); if (this.state !== undefined) { this.setState({ isPlaying: false }); } this._isPlaying = false; } } /** * Set if animation is looping and stops animation after it */ setIsLooping = () => { this.setState({ isLooping: !this.state.isLooping, isPlaying: false }, () => this.stopAnimation()); }; setFramesPerSecond = (fps: number) => { this.setState({ fps: fps, isPlaying: false }, () => this.stopAnimation()); }; /** * Check if the animation has easing predefined */ analyzeAnimationForLerp(animation: Animation | null) { if (animation !== null) { const { easingMode, easingType, usesTangents } = this.getAnimationData(animation); if (easingType === undefined && easingMode === undefined && !usesTangents) { return true; } else { return false; } } else { return false; } } /** * Change the current frame in the canvas and timeline * @param frame value to update on canvas and timeline */ changeCurrentFrame = (frame: number) => { this.stopAnimation(); const animation = this.state.selected; if (animation) { const hasKeyframe = animation.getKeys().find((x) => x.frame === frame); const currentValue = this.calculateCurrentPointInCurve(frame); const value = hasKeyframe ? this.getValueAsArray(animation.dataType, hasKeyframe.value)[this.state.selectedCoordinate] : currentValue ?? 0; const keyframe: IAnimationKey = { frame, value }; this.setState( { currentFrame: frame, isPlaying: false, }, () => { const index = animation.getKeys().findIndex((x) => x.frame === keyframe.frame); const animationName = `${animation.name}_${animation.targetProperty}_${this.state.selectedCoordinate}`; const id = this.encodeCurveId(animationName, index); this.selectKeyframeFromId(id, keyframe); this.setCanvasPosition(keyframe); } ); } }; /** * Calculate the value of the selected frame in curve * @param frame the frame to calculate its current value on the curve */ calculateCurrentPointInCurve = (frame: number): number | undefined => { if ( this.state.selectedPathData !== undefined && this.state.selectedPathData[this.state.selectedCoordinate] !== undefined ) { const selectedCurve = this.state.selectedPathData[this.state.selectedCoordinate].domCurve.current; if (selectedCurve !== null) { const curveLength = selectedCurve.getTotalLength(); const frameValue = (frame * curveLength) / 100; const currentPointInCurve = selectedCurve.getPointAtLength(frameValue); const middle = this._heightScale / 2; const offset = (currentPointInCurve?.y * this._heightScale - this._heightScale ** 2 / 2) / middle / this._heightScale; const unit = Math.sign(offset); const currentValue = unit === -1 ? Math.abs(offset + unit) : unit - offset; this.setState({ currentValue: currentValue, currentPoint: currentPointInCurve, }); return currentValue; } } return undefined; }; /** * Center the position the canvas depending on Keyframe value and frame * @param keyframe the keyframe to set as middle point */ setCanvasPosition = (keyframe: IAnimationKey) => { if (this.state.selected) { // Changes initialframe and last frame const currentFramesInCanvas = this.state.framesInCanvasView.to - this.state.framesInCanvasView.from; const positionX = (keyframe.frame - currentFramesInCanvas / 2) * this._pixelFrameUnit; const newStartFrameInCanvas = Math.round(positionX / this._pixelFrameUnit); let value = 0; if (keyframe.value === null) { value = this.state.panningY; } else { value = this.getValueAsArray(this.state.selected.dataType, keyframe.value)[ this.state.selectedCoordinate ]; } const valueScale = this._heightScale / this._scaleFactor; const middleCanvas = this._heightScale / 2; const positionY = value === 0 ? middleCanvas : middleCanvas - value * valueScale; this.setState({ panningX: positionX, panningY: positionY, repositionCanvas: true, framesInCanvasView: { from: newStartFrameInCanvas, to: newStartFrameInCanvas + currentFramesInCanvas }, }); } }; /** * Sets the current frame * @param frame the value to set the frame */ setCurrentFrame = (frame: number) => { this.setState({ currentFrame: frame, }); }; /** * Change the timeline animation frame limit */ changeAnimationLimit = (limit: number) => { this.stopAnimation(); const doubleLimit = limit * 2; this.setState({ animationLimit: limit, canvasLength: doubleLimit, frameAxisLength: this.setFrameAxis(doubleLimit), }); }; /** * Update the frame in the selected Keyframe */ updateFrameInKeyFrame = (frame: number, index: number) => { if (this.state && this.state.selected) { let animation = this.state.selected; let keys = [...animation.getKeys()]; keys[index].frame = frame; animation.setKeys(keys); this.selectAnimation(animation); } }; /** * Plays or pauses the animation * @param direction 0 stops the animation, 1 starts the animation, -1 plays animation backwards */ playPause = (direction: number) => { this.registerObs(); if (this.state.selected) { let target = this.props.entity; if (this.props.entity instanceof TargetedAnimation) { target = this.props.entity.target; } if (this.state.isPlaying && direction === 0) { this.props.scene.stopAnimation(target); this.setState({ isPlaying: false }); this._isPlaying = false; this.forceUpdate(); } else { if (this.state.isPlaying) { this.props.scene.stopAnimation(target); } this.props.scene.stopAllAnimations(); let keys = this.state.selected.getKeys(); if (keys.length !== 0) { let firstFrame = keys[0].frame; let LastFrame = this.state.selected.getHighestFrame(); if (direction === 1) { this._mainAnimatable = this.props.scene.beginAnimation( target, firstFrame, LastFrame, this.state.isLooping ); } if (direction === -1) { this._mainAnimatable = this.props.scene.beginAnimation( target, LastFrame, firstFrame, this.state.isLooping ); } if (!this.state.isLooping && this._mainAnimatable) { this._mainAnimatable.onAnimationEnd = () => this.playPause(0); } } const zeroFrames = keys.filter((x) => x.frame === 0); if (zeroFrames.length > 1) { keys.shift(); } keys.sort((a, b) => a.frame - b.frame); this._isPlaying = true; this.setState({ isPlaying: true }); this.forceUpdate(); } } }; /** * Set the frame to selected position on canvas * @event e click event on the SVG canvas */ moveFrameTo(e: React.MouseEvent) { this.stopAnimation(); var svg = e.currentTarget as SVGRectElement; var CTM = svg.getScreenCTM(); let position; if (CTM) { position = new Vector2((e.clientX - CTM.e) / CTM.a, (e.clientY - CTM.f) / CTM.d); let selectedFrame = Math.round(position.x / this._pixelFrameUnit); this.setState({ currentFrame: selectedFrame, isPlaying: false }, () => { if (this.state.selected) { const index = this.state.selected.getKeys().findIndex((x) => x.frame === selectedFrame); const keyframe = this.state.selected.getKeys().find((x) => x.frame === selectedFrame); if (index !== undefined && keyframe !== undefined) { const animationName = `${this.state.selected.name}_${this.state.selected.targetProperty}_${this.state.selectedCoordinate}`; const id = this.encodeCurveId(animationName, index); this.selectKeyframeFromId(id, keyframe); this.setCanvasPosition(keyframe); } } }); } } /** * Register the observables for the state */ registerObs() { if (this._onBeforeRenderObserver) { this.props.scene.onBeforeRenderObservable.remove(this._onBeforeRenderObserver); this._onBeforeRenderObserver = null; } this._onBeforeRenderObserver = this.props.scene.onBeforeRenderObservable.add(() => { if (!this._isPlaying || !this._mainAnimatable) { return; } this.setState({ currentFrame: Math.round(this._mainAnimatable.masterFrame), }); }); } /** * Checks if the current frame macthes the parameter * @param frame The frame to check */ isCurrentFrame(frame: number) { return this.state.currentFrame === frame; } /** * Set the Vertical Panning State * @param panningY the vertical panning value */ setPanningY = (panningY: number) => { this.setState({ panningY }); }; /** * Set the Vertical Panning State * @param panningX the horizontal panning value */ setPanningX = (panningX: number) => { const currentFramesInCanvas = this.state.framesInCanvasView.to - this.state.framesInCanvasView.from; const newStartFrameInCanvas = Math.round(panningX / this._pixelFrameUnit); this.setState({ panningX, framesInCanvasView: { from: newStartFrameInCanvas, to: newStartFrameInCanvas + currentFramesInCanvas }, }); }; /** * Set state when canvas has been respositioned */ canvasPositionEnded = () => { this.setState({ repositionCanvas: false }); }; /** * * @param message The message to display in the notification bar */ setNotificationMessage = (message: string) => { this.setState({ notification: message }); }; /** * Zoom or frame the selected keyframes in the available canvas space */ frameSelectedKeyframes = () => { const animation = this.state.selected; const coordinate = this.state.selectedCoordinate; if (animation) { let highest, lowest, firstFrame, lastFrame; const keysCopy = [...animation.getKeys()]; // calculate scale factor for Value Axis // const selectedKeyframes = this.state.svgKeyframes?.filter((x) => x.selected); if (selectedKeyframes?.length === 0) { firstFrame = keysCopy[0].frame; lastFrame = keysCopy[keysCopy.length - 1].frame; // If not selected get all keyframes keysCopy.sort( (a, b) => this.getValueAsArray(animation.dataType, a.value)[coordinate] - this.getValueAsArray(animation.dataType, b.value)[coordinate] ); lowest = keysCopy[0]; highest = keysCopy[keysCopy.length - 1]; keysCopy.sort((a, b) => a.frame - b.frame); } else { // If selected get keys const keysInRange = keysCopy.filter((kf, i) => { return selectedKeyframes?.find((a: IKeyframeSvgPoint) => { const { order } = this.decodeCurveId(a.id); return i === order ? kf : undefined; }); }); // Sort to get first and last frame keysInRange.sort((a, b) => a.frame - b.frame); firstFrame = keysInRange[0].frame; lastFrame = keysInRange[keysInRange.length - 1].frame; // Get previous and next non selected keyframe in range const prevKey = keysCopy[keysCopy.indexOf(keysInRange[0]) - 1]; const nextKey = keysCopy[keysCopy.indexOf(keysInRange[keysInRange.length - 1]) + 1]; // Insert keys in range if (prevKey) { firstFrame = prevKey.frame; keysInRange.push(prevKey); } if (nextKey) { lastFrame = nextKey.frame; keysInRange.push(nextKey); } // Sort to get lowest and highest values for scale keysInRange.sort( (a, b) => this.getValueAsArray(animation.dataType, a.value)[coordinate] - this.getValueAsArray(animation.dataType, b.value)[coordinate] ); lowest = keysInRange[0]; highest = keysInRange[keysInRange.length - 1]; keysInRange.sort((a, b) => a.frame - b.frame); } // calculate scale... const scale = this.getValueAsArray(animation.dataType, highest?.value)[coordinate] - this.getValueAsArray(animation.dataType, lowest?.value)[coordinate]; // Scale Frames to fit width of canvas // reposition canvas to middle value of scale const canvasMargin = 1.5; this._scaleFactor = isNaN(scale) || scale === 0 ? 2 : scale * canvasMargin; // Set a new scale factor but for Frames let currentSpace = 780; const frameUnit = 39; if (this._graphCanvas.current) { currentSpace = this._graphCanvas.current?.clientWidth; } const availableSpaceForFrames = currentSpace / frameUnit; // with client width divide the number of frames needed const frameDistance = lastFrame - firstFrame; this._pixelFrameUnit = availableSpaceForFrames / (frameDistance / 10); // Update scale here... if (this._pixelFrameUnit > 10) { this._pixelFrameUnit = 10; } const canvasValue = isNaN(scale) || scale === 0 ? 1 : scale / 2 + lowest?.value; const centerFrame = frameDistance / 2 + firstFrame; // add margin this.setState( { framesInCanvasView: { from: firstFrame, to: lastFrame }, }, () => { // Need to center and reposition canvas this.setCanvasPosition({ frame: centerFrame, value: canvasValue }); // Render new points this.selectAnimation(animation, coordinate); } ); } }; /** * Handle the frames quantity and scale on Window resize width */ onWindowResizeWidth = () => { let framesResized: number; if (this._graphCanvas.current) { const defaultWidth = 781; const defaultSvgProportion = 1.8; const proportionResized = (defaultSvgProportion / 2) * 10; const svgCanvasViewBoxWidth = 200; const width = (this._graphCanvas.current.clientWidth / svgCanvasViewBoxWidth) * defaultSvgProportion; const percentResize = (this._graphCanvas.current.clientWidth * 100) / defaultWidth; const value = (percentResize - 100) * -1; const unit = 39; framesResized = Math.round(this._graphCanvas.current.clientWidth / unit); this.setState({ valuesPositionResize: value - width + proportionResized, framesResized, }); } this.onTimelineResize(); clearTimeout(this._resizeId); this._resizeId = setTimeout(() => this.onWindowEndResize(framesResized), 300); }; /** * Set the state of frames Resized on window resize event * @param framesResized how many frame have been added or removed on resize */ onWindowEndResize = (framesResized: number) => { const howManyFrames = this.state.framesInCanvasView.to - this.state.framesInCanvasView.from; const difference = framesResized - howManyFrames; const framesInCanvasView = { from: this.state.framesInCanvasView.from - Math.round(difference / 2), to: this.state.framesInCanvasView.to + Math.round(difference / 2), }; this.setState({ framesInCanvasView, }); }; /** Resizes the width of the timeline */ onTimelineResize = () => { if (this._editor.current) { const scrollHandle = this._editor.current.getElementsByClassName("scroll-handle")[0].clientWidth; this._resizedTimeline = scrollHandle; } }; render() { return (
{this.state.svgKeyframes && ( {this.setValueLines().map((line, i) => { return ( {line.label} ); })} {this.setValueLines().map((line, i) => { return ( ); })} {/* Multiple Curves */} {this.state.selectedPathData?.map((curve, i) => ( ))} {this.state.svgKeyframes.map((keyframe, i) => ( ))} this.moveFrameTo(e)} x={-((this.state.frameAxisLength.length * 10) / 2)} y={91 + this.state.panningY + "%"} width={this.state.frameAxisLength.length * 10} height="9%" fill="#222" style={{ cursor: "pointer" }} > {this.state.frameAxisLength.map((f, i) => ( {Math.round((f.label * 10) / this._pixelFrameUnit)} {f.value % this.state.fps === 0 && f.value !== 0 ? ( ) : null} ))} {this.state.selected && this.state.currentFrame !== undefined ? ( {this.state.currentFrame} ) : null} )}
); } }