//
//  walkApi.js
//  version 1.3
//
//  Created by David Wooldridge, June 2015
//  Copyright © 2014 - 2015 High Fidelity, Inc.
//
//  Exposes API for use by walk.js version 1.2+.
//
//  Editing tools for animation data files available here: https://github.com/DaveDubUK/walkTools
//
//  Distributed under the Apache License, Version 2.0.
//  See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//

// included here to ensure walkApi.js can be used as an API, separate from walk.js
Script.include("walkConstants.js");

Avatar = function() {
    // if Hydras are connected, the only way to enable use is to never set any arm joint rotation
    this.hydraCheck = function () {
        return Controller.Hardware.Hydra !== undefined;
    }
    // settings
    this.headFree = true;
    this.armsFree = this.hydraCheck(); // automatically sets true to enable Hydra support - temporary fix
    this.makesFootStepSounds = false;
    this.blenderPreRotations = false; // temporary fix
    this.animationSet = undefined; // currently just one animation set
    this.setAnimationSet = function(animationSet) {
        this.animationSet = animationSet;
        switch (animationSet) {
            case 'standardMale':
                this.selectedIdle = walkAssets.getAnimationDataFile("MaleIdle");
                this.selectedWalk = walkAssets.getAnimationDataFile("MaleWalk");
                this.selectedWalkBackwards = walkAssets.getAnimationDataFile("MaleWalkBackwards");
                this.selectedSideStepLeft = walkAssets.getAnimationDataFile("MaleSideStepLeft");
                this.selectedSideStepRight = walkAssets.getAnimationDataFile("MaleSideStepRight");
                this.selectedWalkBlend = walkAssets.getAnimationDataFile("WalkBlend");
                this.selectedHover = walkAssets.getAnimationDataFile("MaleHover");
                this.selectedFly = walkAssets.getAnimationDataFile("MaleFly");
                this.selectedFlyBackwards = walkAssets.getAnimationDataFile("MaleFlyBackwards");
                this.selectedFlyDown = walkAssets.getAnimationDataFile("MaleFlyDown");
                this.selectedFlyUp = walkAssets.getAnimationDataFile("MaleFlyUp");
                this.selectedFlyBlend = walkAssets.getAnimationDataFile("FlyBlend");
                this.currentAnimation = this.selectedIdle;
                return;
        }
    }
    this.setAnimationSet('standardMale');

    // calibration
    this.calibration = {
        hipsToFeet: 1,
        strideLength: this.selectedWalk.calibration.strideLength
    }
    this.distanceFromSurface = 0;
    this.calibrate = function() {
        // Triple check: measurements are taken three times to ensure accuracy - the first result is often too large
        const MAX_ATTEMPTS = 3;
        var attempts = MAX_ATTEMPTS;
        var extraAttempts = 0;
        do {
            for (joint in walkAssets.animationReference.joints) {
                var IKChain = walkAssets.animationReference.joints[joint].IKChain;

                // only need to zero right leg IK chain and hips
                if (IKChain === "RightLeg" || joint === "Hips" ) {
                    MyAvatar.setJointRotation(joint, Quat.fromPitchYawRollDegrees(0, 0, 0));
                }
            }
            this.calibration.hipsToFeet = MyAvatar.getJointPosition("Hips").y - MyAvatar.getJointPosition("RightToeBase").y;

            // maybe measuring before Blender pre-rotations have been applied?
            if (this.calibration.hipsToFeet < 0 && this.blenderPreRotations) {
                this.calibration.hipsToFeet *= -1;
            }

            if (this.calibration.hipsToFeet === 0 && extraAttempts < 100) {
                attempts++;
                extraAttempts++;// Interface can sometimes report zero for hips to feet. if so, we try again.
            }
        } while (attempts-- > 1)

        // just in case
        if (this.calibration.hipsToFeet <= 0 || isNaN(this.calibration.hipsToFeet)) {
            this.calibration.hipsToFeet = 1;
            print('walk.js error: Unable to get a non-zero measurement for the avatar hips to feet measure. Hips to feet set to default value ('+
                  this.calibration.hipsToFeet.toFixed(3)+'m). This will cause some foot sliding. If your avatar has only just appeared, it is recommended that you re-load the walk script.');
        } else {
            print('walk.js info: Hips to feet calibrated to '+this.calibration.hipsToFeet.toFixed(3)+'m');
        }
    }

    // pose the fingers
    this.poseFingers = function() {
        for (knuckle in walkAssets.animationReference.leftHand) {
            if (walkAssets.animationReference.leftHand[knuckle].IKChain === "LeftHandThumb") {
                MyAvatar.setJointRotation(knuckle, Quat.fromPitchYawRollDegrees(0, 0, -4));
            } else {
                MyAvatar.setJointRotation(knuckle, Quat.fromPitchYawRollDegrees(16, 0, 5));
            }
        }
        for (knuckle in walkAssets.animationReference.rightHand) {
            if (walkAssets.animationReference.rightHand[knuckle].IKChain === "RightHandThumb") {
                MyAvatar.setJointRotation(knuckle, Quat.fromPitchYawRollDegrees(0, 0, 4));
            } else {
                MyAvatar.setJointRotation(knuckle, Quat.fromPitchYawRollDegrees(16, 0, -5));
            }
        }
    };
    this.calibrate();
    this.poseFingers();

    // footsteps
    this.nextStep = RIGHT; // the first step is right, because the waveforms say so
    this.leftAudioInjector = null;
    this.rightAudioInjector = null;
    this.makeFootStepSound = function() {
        // correlate footstep volume with avatar speed. place the audio source at the feet, not the hips
        const SPEED_THRESHOLD = 0.4;
        const VOLUME_ATTENUATION = 0.8;
        const MIN_VOLUME = 0.5;
        var volume = Vec3.length(motion.velocity) > SPEED_THRESHOLD ?
                     VOLUME_ATTENUATION * Vec3.length(motion.velocity) / MAX_WALK_SPEED : MIN_VOLUME;
        volume = volume > 1 ? 1 : volume; // occurs when landing at speed - can walk faster than max walking speed
        var options = {
            position: Vec3.sum(MyAvatar.position, {x:0, y: -this.calibration.hipsToFeet, z:0}),
            volume: volume
        };
        if (this.nextStep === RIGHT) {
            if (this.rightAudioInjector === null) {
                this.rightAudioInjector = Audio.playSound(walkAssets.footsteps[0], options);
            } else {
                this.rightAudioInjector.setOptions(options);
                this.rightAudioInjector.restart();
            }
            this.nextStep = LEFT;
        } else if (this.nextStep === LEFT) {
            if (this.leftAudioInjector === null) {
                this.leftAudioInjector = Audio.playSound(walkAssets.footsteps[1], options);
            } else {
                this.leftAudioInjector.setOptions(options);
                this.leftAudioInjector.restart();
            }
            this.nextStep = RIGHT;
        }
    }
};

// constructor for the Motion object
Motion = function() {
    this.isLive = true;
    // locomotion status
    this.state = STATIC;
    this.nextState = STATIC;
    this.isMoving = false;
    this.isWalkingSpeed = false;
    this.isFlyingSpeed = false;
    this.isAccelerating = false;
    this.isDecelerating = false;
    this.isDeceleratingFast = false;
    this.isComingToHalt = false;
    this.directedAcceleration = 0;

    // used to make sure at least one step has been taken when transitioning from a walk cycle
    this.elapsedFTDegrees = 0;

    // the current transition (any layered transitions are nested within this transition)
    this.currentTransition = null;

    // orientation, locomotion and timing
    this.velocity = {x:0, y:0, z:0};
    this.acceleration = {x:0, y:0, z:0};
    this.yaw = Quat.safeEulerAngles(MyAvatar.orientation).y;
    this.yawDelta = 0;
    this.yawDeltaAcceleration = 0;
    this.direction = FORWARDS;
    this.deltaTime = 0;

    // historical orientation, locomotion and timing
    this.lastDirection = FORWARDS;
    this.lastVelocity = {x:0, y:0, z:0};
    this.lastYaw = Quat.safeEulerAngles(MyAvatar.orientation).y;
    this.lastYawDelta = 0;
    this.lastYawDeltaAcceleration = 0;

    // Quat.safeEulerAngles(MyAvatar.orientation).y tends to repeat values between frames, so values are filtered
    var YAW_SMOOTHING = 22;
    this.yawFilter = filter.createAveragingFilter(YAW_SMOOTHING);
    this.deltaTimeFilter = filter.createAveragingFilter(YAW_SMOOTHING);
    this.yawDeltaAccelerationFilter = filter.createAveragingFilter(YAW_SMOOTHING);

    // assess locomotion state
    this.assess = function(deltaTime) {
        // calculate avatar frame speed, velocity and acceleration
        this.deltaTime = deltaTime;
        this.velocity = Vec3.multiplyQbyV(Quat.inverse(MyAvatar.orientation), MyAvatar.getVelocity());
        var lateralVelocity = Math.sqrt(Math.pow(this.velocity.x, 2) + Math.pow(this.velocity.z, 2));

        // MyAvatar.getAcceleration() currently not working. bug report submitted: https://worklist.net/20527
        var acceleration = {x:0, y:0, z:0};
        this.acceleration.x = (this.velocity.x - this.lastVelocity.x) / deltaTime;
        this.acceleration.y = (this.velocity.y - this.lastVelocity.y) / deltaTime;
        this.acceleration.z = (this.velocity.z - this.lastVelocity.z) / deltaTime;

        // MyAvatar.getAngularVelocity and MyAvatar.getAngularAcceleration currently not working. bug report submitted
        this.yaw = Quat.safeEulerAngles(MyAvatar.orientation).y;
        if (this.lastYaw < 0 && this.yaw > 0 || this.lastYaw > 0 && this.yaw < 0) {
            this.lastYaw *= -1;
        }
        var timeDelta = this.deltaTimeFilter.process(deltaTime);
        this.yawDelta = filter.degToRad(this.yawFilter.process(this.lastYaw - this.yaw)) / timeDelta;
        this.yawDeltaAcceleration = this.yawDeltaAccelerationFilter.process(this.lastYawDelta - this.yawDelta) / timeDelta;

        // how far above the surface is the avatar? (for testing / validation purposes)
        var pickRay = {origin: MyAvatar.position, direction: {x:0, y:-1, z:0}};
        var distanceFromSurface = Entities.findRayIntersectionBlocking(pickRay).distance;
        avatar.distanceFromSurface = distanceFromSurface - avatar.calibration.hipsToFeet;

        // determine principle direction of locomotion
        var FWD_BACK_BIAS = 100; // helps prevent false sidestep condition detection when banking hard
        if (Math.abs(this.velocity.x) > Math.abs(this.velocity.y) &&
            Math.abs(this.velocity.x) > FWD_BACK_BIAS * Math.abs(this.velocity.z)) {
            if (this.velocity.x < 0) {
                this.directedAcceleration = -this.acceleration.x;
                this.direction = LEFT;
            } else if (this.velocity.x > 0){
                this.directedAcceleration = this.acceleration.x;
                this.direction = RIGHT;
            }
        } else if (Math.abs(this.velocity.y) > Math.abs(this.velocity.x) &&
                   Math.abs(this.velocity.y) > Math.abs(this.velocity.z)) {
            if (this.velocity.y > 0) {
                this.directedAcceleration = this.acceleration.y;
                this.direction = UP;
            } else if (this.velocity.y < 0) {
                this.directedAcceleration = -this.acceleration.y;
                this.direction = DOWN;
            }
        } else if (FWD_BACK_BIAS * Math.abs(this.velocity.z) > Math.abs(this.velocity.x) &&
                   Math.abs(this.velocity.z) > Math.abs(this.velocity.y)) {
            if (this.velocity.z < 0) {
                this.direction = FORWARDS;
                this.directedAcceleration = -this.acceleration.z;
            } else if (this.velocity.z > 0) {
                this.directedAcceleration = this.acceleration.z;
                this.direction = BACKWARDS;
            }
        } else {
            this.direction = NONE;
            this.directedAcceleration = 0;
        }

        // set speed flags
        if (Vec3.length(this.velocity) < MOVE_THRESHOLD) {
            this.isMoving = false;
            this.isWalkingSpeed = false;
            this.isFlyingSpeed = false;
            this.isComingToHalt = false;
        } else if (Vec3.length(this.velocity) < MAX_WALK_SPEED) {
            this.isMoving = true;
            this.isWalkingSpeed = true;
            this.isFlyingSpeed = false;
        } else {
            this.isMoving = true;
            this.isWalkingSpeed = false;
            this.isFlyingSpeed = true;
        }

        // set acceleration flags
        if (this.directedAcceleration > ACCELERATION_THRESHOLD) {
            this.isAccelerating = true;
            this.isDecelerating = false;
            this.isDeceleratingFast = false;
            this.isComingToHalt = false;
        } else if (this.directedAcceleration < DECELERATION_THRESHOLD) {
            this.isAccelerating = false;
            this.isDecelerating = true;
            this.isDeceleratingFast = (this.directedAcceleration < FAST_DECELERATION_THRESHOLD);
        } else {
            this.isAccelerating = false;
            this.isDecelerating = false;
            this.isDeceleratingFast = false;
        }

        // use the gathered information to build up some spatial awareness
        var isOnSurface = (avatar.distanceFromSurface < ON_SURFACE_THRESHOLD);
        var isUnderGravity = (avatar.distanceFromSurface < GRAVITY_THRESHOLD);
        var isTakingOff = (isUnderGravity && this.velocity.y > OVERCOME_GRAVITY_SPEED);
        var isComingInToLand = (isUnderGravity && this.velocity.y < -OVERCOME_GRAVITY_SPEED);
        var aboutToLand = isComingInToLand && avatar.distanceFromSurface < LANDING_THRESHOLD;
        var surfaceMotion = isOnSurface && this.isMoving;
        var acceleratingAndAirborne = this.isAccelerating && !isOnSurface;
        var goingTooFastToWalk = !this.isDecelerating && this.isFlyingSpeed;
        var movingDirectlyUpOrDown = (this.direction === UP || this.direction === DOWN)
        var maybeBouncing = Math.abs(this.acceleration.y > BOUNCE_ACCELERATION_THRESHOLD) ? true : false;

        // we now have enough information to set the appropriate locomotion mode
        switch (this.state) {
            case STATIC:
                var staticToAirMotion = this.isMoving && (acceleratingAndAirborne || goingTooFastToWalk ||
                                                           (movingDirectlyUpOrDown && !isOnSurface));
                var staticToSurfaceMotion = surfaceMotion && !motion.isComingToHalt && !movingDirectlyUpOrDown &&
                                            !this.isDecelerating && lateralVelocity > MOVE_THRESHOLD;

                if (staticToAirMotion) {
                    this.nextState = AIR_MOTION;
                } else if (staticToSurfaceMotion) {
                    this.nextState = SURFACE_MOTION;
                } else {
                    this.nextState = STATIC;
                }
                break;

            case SURFACE_MOTION:
                var surfaceMotionToStatic = !this.isMoving ||
                                            (this.isDecelerating && motion.lastDirection !== DOWN && surfaceMotion &&
                                            !maybeBouncing && Vec3.length(this.velocity) < MAX_WALK_SPEED);
                var surfaceMotionToAirMotion = (acceleratingAndAirborne || goingTooFastToWalk || movingDirectlyUpOrDown) &&
                                               (!surfaceMotion && isTakingOff) ||
                                               (!surfaceMotion && this.isMoving && !isComingInToLand);

                if (surfaceMotionToStatic) {
                    // working on the assumption that stopping is now inevitable
                    if (!motion.isComingToHalt && isOnSurface) {
                        motion.isComingToHalt = true;
                    }
                    this.nextState = STATIC;
                } else if (surfaceMotionToAirMotion) {
                    this.nextState = AIR_MOTION;
                } else {
                    this.nextState = SURFACE_MOTION;
                }
                break;

            case AIR_MOTION:
                var airMotionToSurfaceMotion = (surfaceMotion || aboutToLand) && !movingDirectlyUpOrDown;
                var airMotionToStatic = !this.isMoving && this.direction === this.lastDirection;

                if (airMotionToSurfaceMotion){
                    this.nextState = SURFACE_MOTION;
                } else if (airMotionToStatic) {
                    this.nextState = STATIC;
                } else {
                    this.nextState = AIR_MOTION;
                }
                break;
        }
    }

    // frequency time wheel (foot / ground speed matching)
    const DEFAULT_HIPS_TO_FEET = 1;
    this.frequencyTimeWheelPos = 0;
    this.frequencyTimeWheelRadius = DEFAULT_HIPS_TO_FEET / 2;
    this.recentFrequencyTimeIncrements = [];
    const FT_WHEEL_HISTORY_LENGTH = 8;
    for (var i = 0; i < FT_WHEEL_HISTORY_LENGTH; i++) {
        this.recentFrequencyTimeIncrements.push(0);
    }
    this.averageFrequencyTimeIncrement = 0;

    this.advanceFrequencyTimeWheel = function(angle){
        this.elapsedFTDegrees += angle;
        // keep a running average of increments for use in transitions (used during transitioning)
        this.recentFrequencyTimeIncrements.push(angle);
        this.recentFrequencyTimeIncrements.shift();
        for (increment in this.recentFrequencyTimeIncrements) {
            this.averageFrequencyTimeIncrement += this.recentFrequencyTimeIncrements[increment];
        }
        this.averageFrequencyTimeIncrement /= this.recentFrequencyTimeIncrements.length;
        this.frequencyTimeWheelPos += angle;
        const FULL_CIRCLE = 360;
        if (this.frequencyTimeWheelPos >= FULL_CIRCLE) {
            this.frequencyTimeWheelPos = this.frequencyTimeWheelPos % FULL_CIRCLE;
        }
    }

    this.saveHistory = function() {
        this.lastDirection = this.direction;
        this.lastVelocity = this.velocity;
        this.lastYaw = this.yaw;
        this.lastYawDelta = this.yawDelta;
        this.lastYawDeltaAcceleration = this.yawDeltaAcceleration;
    }
};  // end Motion constructor

// animation manipulation object
animationOperations = (function() {

    return {

        // helper function for renderMotion(). calculate joint translations based on animation file settings and frequency * time
        calculateTranslations: function(animation, ft, direction) {
            var jointName = "Hips";
            var joint = animation.joints[jointName];
            var jointTranslations = {x:0, y:0, z:0};

            // gather modifiers and multipliers
            modifiers = new FrequencyMultipliers(joint, direction);

            // calculate translations. Use synthesis filters where specified by the animation data file.

            // sway (oscillation on the x-axis)
            if (animation.filters.hasOwnProperty(jointName) && 'swayFilter' in animation.filters[jointName]) {
                jointTranslations.x = joint.sway * animation.filters[jointName].swayFilter.calculate
                    (filter.degToRad(modifiers.swayFrequencyMultiplier * ft + joint.swayPhase)) + joint.swayOffset;
            } else {
                jointTranslations.x = joint.sway * Math.sin
                    (filter.degToRad(modifiers.swayFrequencyMultiplier * ft + joint.swayPhase)) + joint.swayOffset;
            }
            // bob (oscillation on the y-axis)
            if (animation.filters.hasOwnProperty(jointName) && 'bobFilter' in animation.filters[jointName]) {
                jointTranslations.y = joint.bob * animation.filters[jointName].bobFilter.calculate
                    (filter.degToRad(modifiers.bobFrequencyMultiplier * ft + joint.bobPhase)) + joint.bobOffset;
            } else {
                jointTranslations.y = joint.bob * Math.sin
                    (filter.degToRad(modifiers.bobFrequencyMultiplier * ft + joint.bobPhase)) + joint.bobOffset;

                if (animation.filters.hasOwnProperty(jointName) && 'bobLPFilter' in animation.filters[jointName]) {
                    jointTranslations.y = filter.clipTrough(jointTranslations.y, joint, 2);
                    jointTranslations.y = animation.filters[jointName].bobLPFilter.process(jointTranslations.y);
                }
            }
            // thrust (oscillation on the z-axis)
            if (animation.filters.hasOwnProperty(jointName) && 'thrustFilter' in animation.filters[jointName]) {
                jointTranslations.z = joint.thrust * animation.filters[jointName].thrustFilter.calculate
                    (filter.degToRad(modifiers.thrustFrequencyMultiplier * ft + joint.thrustPhase)) + joint.thrustOffset;
            } else {
                jointTranslations.z = joint.thrust * Math.sin
                    (filter.degToRad(modifiers.thrustFrequencyMultiplier * ft + joint.thrustPhase)) + joint.thrustOffset;
            }
            return jointTranslations;
        },

        // helper function for renderMotion(). calculate joint rotations based on animation file settings and frequency * time
        calculateRotations: function(jointName, animation, ft, direction) {
            var joint = animation.joints[jointName];
            var jointRotations = {x:0, y:0, z:0};

            if (avatar.blenderPreRotations) {
                jointRotations = Vec3.sum(jointRotations, walkAssets.blenderPreRotations.joints[jointName]);
            }

            // gather frequency multipliers for this joint
            modifiers = new FrequencyMultipliers(joint, direction);

            // calculate rotations. Use synthesis filters where specified by the animation data file.

            // calculate pitch
            if (animation.filters.hasOwnProperty(jointName) &&
               'pitchFilter' in animation.filters[jointName]) {
                jointRotations.x += joint.pitch * animation.filters[jointName].pitchFilter.calculate
                    (filter.degToRad(ft * modifiers.pitchFrequencyMultiplier + joint.pitchPhase)) + joint.pitchOffset;
            } else {
                jointRotations.x += joint.pitch * Math.sin
                    (filter.degToRad(ft * modifiers.pitchFrequencyMultiplier + joint.pitchPhase)) + joint.pitchOffset;
            }
            // calculate yaw
            if (animation.filters.hasOwnProperty(jointName) &&
               'yawFilter' in animation.filters[jointName]) {
                jointRotations.y += joint.yaw * animation.filters[jointName].yawFilter.calculate
                    (filter.degToRad(ft * modifiers.yawFrequencyMultiplier + joint.yawPhase)) + joint.yawOffset;
            } else {
                jointRotations.y += joint.yaw * Math.sin
                    (filter.degToRad(ft * modifiers.yawFrequencyMultiplier + joint.yawPhase)) + joint.yawOffset;
            }
            // calculate roll
            if (animation.filters.hasOwnProperty(jointName) &&
               'rollFilter' in animation.filters[jointName]) {
                jointRotations.z += joint.roll * animation.filters[jointName].rollFilter.calculate
                    (filter.degToRad(ft * modifiers.rollFrequencyMultiplier + joint.rollPhase)) + joint.rollOffset;
            } else {
                jointRotations.z += joint.roll * Math.sin
                    (filter.degToRad(ft * modifiers.rollFrequencyMultiplier + joint.rollPhase)) + joint.rollOffset;
            }
            return jointRotations;
        },

        zeroAnimation: function(animation) {
            for (i in animation.joints) {
                for (j in animation.joints[i]) {
                    animation.joints[i][j] = 0;
                }
            }
        },

        blendAnimation: function(sourceAnimation, targetAnimation, percent) {
            for (i in targetAnimation.joints) {
                targetAnimation.joints[i].pitch += percent * sourceAnimation.joints[i].pitch;
                targetAnimation.joints[i].yaw += percent * sourceAnimation.joints[i].yaw;
                targetAnimation.joints[i].roll += percent * sourceAnimation.joints[i].roll;
                targetAnimation.joints[i].pitchPhase += percent * sourceAnimation.joints[i].pitchPhase;
                targetAnimation.joints[i].yawPhase += percent * sourceAnimation.joints[i].yawPhase;
                targetAnimation.joints[i].rollPhase += percent * sourceAnimation.joints[i].rollPhase;
                targetAnimation.joints[i].pitchOffset += percent * sourceAnimation.joints[i].pitchOffset;
                targetAnimation.joints[i].yawOffset += percent * sourceAnimation.joints[i].yawOffset;
                targetAnimation.joints[i].rollOffset += percent * sourceAnimation.joints[i].rollOffset;
                if (i === "Hips") {
                    // Hips only
                    targetAnimation.joints[i].thrust += percent * sourceAnimation.joints[i].thrust;
                    targetAnimation.joints[i].sway += percent * sourceAnimation.joints[i].sway;
                    targetAnimation.joints[i].bob += percent * sourceAnimation.joints[i].bob;
                    targetAnimation.joints[i].thrustPhase += percent * sourceAnimation.joints[i].thrustPhase;
                    targetAnimation.joints[i].swayPhase += percent * sourceAnimation.joints[i].swayPhase;
                    targetAnimation.joints[i].bobPhase += percent * sourceAnimation.joints[i].bobPhase;
                    targetAnimation.joints[i].thrustOffset += percent * sourceAnimation.joints[i].thrustOffset;
                    targetAnimation.joints[i].swayOffset += percent * sourceAnimation.joints[i].swayOffset;
                    targetAnimation.joints[i].bobOffset += percent * sourceAnimation.joints[i].bobOffset;
                }
            }
        },

        deepCopy: function(sourceAnimation, targetAnimation) {
            // calibration
            targetAnimation.calibration = JSON.parse(JSON.stringify(sourceAnimation.calibration));

            // harmonics
            targetAnimation.harmonics = {};
            if (sourceAnimation.harmonics) {
                targetAnimation.harmonics = JSON.parse(JSON.stringify(sourceAnimation.harmonics));
            }

            // filters
            targetAnimation.filters = {};
            for (i in sourceAnimation.filters) {
                // are any filters specified for this joint?
                if (sourceAnimation.filters[i]) {
                    targetAnimation.filters[i] = sourceAnimation.filters[i];
                    // wave shapers
                    if (sourceAnimation.filters[i].pitchFilter) {
                        targetAnimation.filters[i].pitchFilter = sourceAnimation.filters[i].pitchFilter;
                    }
                    if (sourceAnimation.filters[i].yawFilter) {
                        targetAnimation.filters[i].yawFilter = sourceAnimation.filters[i].yawFilter;
                    }
                    if (sourceAnimation.filters[i].rollFilter) {
                        targetAnimation.filters[i].rollFilter = sourceAnimation.filters[i].rollFilter;
                    }
                    // LP filters
                    if (sourceAnimation.filters[i].swayLPFilter) {
                        targetAnimation.filters[i].swayLPFilter = sourceAnimation.filters[i].swayLPFilter;
                    }
                    if (sourceAnimation.filters[i].bobLPFilter) {
                        targetAnimation.filters[i].bobLPFilter = sourceAnimation.filters[i].bobLPFilter;
                    }
                    if (sourceAnimation.filters[i].thrustLPFilter) {
                        targetAnimation.filters[i].thrustLPFilter = sourceAnimation.filters[i].thrustLPFilter;
                    }
                }
            }
            // joints
            targetAnimation.joints = JSON.parse(JSON.stringify(sourceAnimation.joints));
        }
    }

})(); // end animation object literal

// ReachPose datafile wrapper object
ReachPose = function(reachPoseName) {
    this.name = reachPoseName;
    this.reachPoseParameters = walkAssets.getReachPoseParameters(reachPoseName);
    this.reachPoseDataFile = walkAssets.getReachPoseDataFile(reachPoseName);
    this.progress = 0;
    this.smoothingFilter = filter.createAveragingFilter(this.reachPoseParameters.smoothing);
    this.currentStrength = function() {
        // apply optionally smoothed (D)ASDR envelope to reach pose's strength / influence whilst active
        var segmentProgress = undefined; // progress through chosen segment
        var segmentTimeDelta = undefined; // total change in time over chosen segment
        var segmentStrengthDelta = undefined; // total change in strength over chosen segment
        var lastStrength = undefined; // the last value the previous segment held
        var currentStrength = undefined; // return value

        // select parameters based on segment (a segment being one of (D),A,S,D or R)
        if (this.progress >= this.reachPoseParameters.sustain.timing) {
            // release segment
            segmentProgress = this.progress - this.reachPoseParameters.sustain.timing;
            segmentTimeDelta = this.reachPoseParameters.release.timing - this.reachPoseParameters.sustain.timing;
            segmentStrengthDelta = this.reachPoseParameters.release.strength - this.reachPoseParameters.sustain.strength;
            lastStrength = this.reachPoseParameters.sustain.strength;
        } else if (this.progress >= this.reachPoseParameters.decay.timing) {
            // sustain phase
            segmentProgress = this.progress - this.reachPoseParameters.decay.timing;
            segmentTimeDelta = this.reachPoseParameters.sustain.timing - this.reachPoseParameters.decay.timing;
            segmentStrengthDelta = this.reachPoseParameters.sustain.strength - this.reachPoseParameters.decay.strength;
            lastStrength = this.reachPoseParameters.decay.strength;
        } else if (this.progress >= this.reachPoseParameters.attack.timing) {
            // decay phase
            segmentProgress = this.progress - this.reachPoseParameters.attack.timing;
            segmentTimeDelta = this.reachPoseParameters.decay.timing - this.reachPoseParameters.attack.timing;
            segmentStrengthDelta = this.reachPoseParameters.decay.strength - this.reachPoseParameters.attack.strength;
            lastStrength = this.reachPoseParameters.attack.strength;
        } else if (this.progress >= this.reachPoseParameters.delay.timing) {
            // attack phase
            segmentProgress = this.progress - this.reachPoseParameters.delay.timing;
            segmentTimeDelta = this.reachPoseParameters.attack.timing - this.reachPoseParameters.delay.timing;
            segmentStrengthDelta = this.reachPoseParameters.attack.strength - this.reachPoseParameters.delay.strength;
            lastStrength = 0; //this.delay.strength;
        } else {
            // delay phase
            segmentProgress = this.progress;
            segmentTimeDelta = this.reachPoseParameters.delay.timing;
            segmentStrengthDelta = this.reachPoseParameters.delay.strength;
            lastStrength = 0;
        }
        currentStrength = segmentTimeDelta > 0 ? lastStrength + segmentStrengthDelta * segmentProgress / segmentTimeDelta
                                               : lastStrength;
        // smooth off the response curve
        currentStrength = this.smoothingFilter.process(currentStrength);
        return currentStrength;
    }
};

// constructor with default parameters
TransitionParameters = function() {
    this.duration = 0.5;
    this.easingLower = {x:0.25, y:0.75};
    this.easingUpper = {x:0.75, y:0.25};
    this.reachPoses = [];
}

const QUARTER_CYCLE = 90;
const HALF_CYCLE = 180;
const THREE_QUARTER_CYCLE = 270;
const FULL_CYCLE = 360;

// constructor for animation Transition
Transition = function(nextAnimation, lastAnimation, lastTransition, playTransitionReachPoses) {

    if (playTransitionReachPoses === undefined) {
        playTransitionReachPoses = true;
    }

    // record the current state of animation
    this.nextAnimation = nextAnimation;
    this.lastAnimation = lastAnimation;
    this.lastTransition = lastTransition;

    // collect information about the currently playing animation
    this.direction = motion.direction;
    this.lastDirection = motion.lastDirection;
    this.lastFrequencyTimeWheelPos = motion.frequencyTimeWheelPos;
    this.lastFrequencyTimeIncrement = motion.averageFrequencyTimeIncrement;
    this.lastFrequencyTimeWheelRadius = motion.frequencyTimeWheelRadius;
    this.degreesToTurn = 0; // total degrees to turn the ft wheel before the avatar stops (walk only)
    this.degreesRemaining = 0; // remaining degrees to turn the ft wheel before the avatar stops (walk only)
    this.lastElapsedFTDegrees = motion.elapsedFTDegrees; // degrees elapsed since last transition start
    motion.elapsedFTDegrees = 0; // reset ready for the next transition
    motion.frequencyTimeWheelPos = 0; // start the next animation's frequency time wheel from zero

    // set parameters for the transition
    this.parameters = new TransitionParameters();
    this.liveReachPoses = [];
    if (walkAssets && lastAnimation && nextAnimation) {
        // overwrite this.parameters with any transition parameters specified for this particular transition
        walkAssets.getTransitionParameters(lastAnimation, nextAnimation, this.parameters);
        // fire up any reach poses for this transition
        if (playTransitionReachPoses) {
            for (poseName in this.parameters.reachPoses) {
                this.liveReachPoses.push(new ReachPose(this.parameters.reachPoses[poseName]));
            }
        }
    }
    this.startTime = new Date().getTime(); // Starting timestamp (seconds)
    this.progress = 0; // how far are we through the transition?
    this.filteredProgress = 0;

    // coming to a halt whilst walking? if so, will need a clean stopping point defined
    if (motion.isComingToHalt) {

        const FULL_CYCLE_THRESHOLD = 320;
        const HALF_CYCLE_THRESHOLD = 140;
        const CYCLE_COMMIT_THRESHOLD = 5;

        // how many degrees do we need to turn the walk wheel to finish walking with both feet on the ground?
        if (this.lastElapsedFTDegrees < CYCLE_COMMIT_THRESHOLD) {
            // just stop the walk cycle right here and blend to idle
            this.degreesToTurn = 0;
        } else if (this.lastElapsedFTDegrees < HALF_CYCLE) {
            // we have not taken a complete step yet, so we advance to the second stop angle
            this.degreesToTurn = HALF_CYCLE  - this.lastFrequencyTimeWheelPos;
        } else if (this.lastFrequencyTimeWheelPos > 0 && this.lastFrequencyTimeWheelPos <= HALF_CYCLE_THRESHOLD) {
            // complete the step and stop at 180
            this.degreesToTurn = HALF_CYCLE - this.lastFrequencyTimeWheelPos;
        } else if (this.lastFrequencyTimeWheelPos > HALF_CYCLE_THRESHOLD && this.lastFrequencyTimeWheelPos <= HALF_CYCLE) {
            // complete the step and next then stop at 0
            this.degreesToTurn = HALF_CYCLE - this.lastFrequencyTimeWheelPos + HALF_CYCLE;
        } else if (this.lastFrequencyTimeWheelPos > HALF_CYCLE && this.lastFrequencyTimeWheelPos <= FULL_CYCLE_THRESHOLD) {
            // complete the step and stop at 0
            this.degreesToTurn = FULL_CYCLE - this.lastFrequencyTimeWheelPos;
        } else {
            // complete the step and the next then stop at 180
            this.degreesToTurn = FULL_CYCLE - this.lastFrequencyTimeWheelPos + HALF_CYCLE;
        }

        // transition length in this case should be directly proportional to the remaining degrees to turn
        var MIN_FT_INCREMENT = 5.0; // degrees per frame
        var MIN_TRANSITION_DURATION = 0.4;
        const TWO_THIRDS = 0.6667;
        this.lastFrequencyTimeIncrement *= TWO_THIRDS; // help ease the transition
        var lastFrequencyTimeIncrement = this.lastFrequencyTimeIncrement > MIN_FT_INCREMENT ?
                                         this.lastFrequencyTimeIncrement : MIN_FT_INCREMENT;
        var timeToFinish = Math.max(motion.deltaTime * this.degreesToTurn / lastFrequencyTimeIncrement,
                                    MIN_TRANSITION_DURATION);
        this.parameters.duration = timeToFinish;
        this.degreesRemaining = this.degreesToTurn;
    }

    // deal with transition recursion (overlapping transitions)
    this.recursionDepth = 0;
    this.incrementRecursion = function() {
        this.recursionDepth += 1;

        // cancel any continued motion
        this.degreesToTurn = 0;

        // limit the number of layered / nested transitions
        if (this.lastTransition !== nullTransition) {
            this.lastTransition.incrementRecursion();
            if (this.lastTransition.recursionDepth > MAX_TRANSITION_RECURSION) {
                this.lastTransition = nullTransition;
            }
        }
    };
    if (this.lastTransition !== nullTransition) {
        this.lastTransition.incrementRecursion();
    }

    // end of transition initialisation. begin Transition public methods

    // keep up the pace for the frequency time wheel for the last animation
    this.advancePreviousFrequencyTimeWheel = function(deltaTime) {
        var wheelAdvance = undefined;

        if (this.lastAnimation === avatar.selectedWalkBlend &&
            this.nextAnimation === avatar.selectedIdle) {
            if (this.degreesRemaining <= 0) {
                // stop continued motion
                wheelAdvance = 0;
                if (motion.isComingToHalt) {
                    if (this.lastFrequencyTimeWheelPos < QUARTER_CYCLE) {
                        this.lastFrequencyTimeWheelPos = 0;
                    } else {
                        this.lastFrequencyTimeWheelPos = HALF_CYCLE;
                    }
                }
            } else {
                wheelAdvance = this.lastFrequencyTimeIncrement;
                var distanceToTravel = avatar.calibration.strideLength * wheelAdvance / HALF_CYCLE;
                if (this.degreesRemaining <= 0) {
                    distanceToTravel = 0;
                    this.degreesRemaining = 0;
                } else {
                    this.degreesRemaining -= wheelAdvance;
                }
            }
        } else {
            wheelAdvance = this.lastFrequencyTimeIncrement;
        }

        // advance the ft wheel
        this.lastFrequencyTimeWheelPos += wheelAdvance;
        if (this.lastFrequencyTimeWheelPos >= FULL_CYCLE) {
            this.lastFrequencyTimeWheelPos = this.lastFrequencyTimeWheelPos % FULL_CYCLE;
        }

        // advance ft wheel for the nested (previous) Transition
        if (this.lastTransition !== nullTransition) {
            this.lastTransition.advancePreviousFrequencyTimeWheel(deltaTime);
        }
        // update the lastElapsedFTDegrees for short stepping
        this.lastElapsedFTDegrees += wheelAdvance;
        this.degreesTurned += wheelAdvance;
    };

    this.updateProgress = function() {
        const MILLISECONDS_CONVERT = 1000;
        const ACCURACY_INCREASER = 1000;
        var elapasedTime = (new Date().getTime() - this.startTime) / MILLISECONDS_CONVERT;
        this.progress = elapasedTime / this.parameters.duration;
        this.progress = Math.round(this.progress * ACCURACY_INCREASER) / ACCURACY_INCREASER;

        // updated nested transition/s
        if (this.lastTransition !== nullTransition) {
            if (this.lastTransition.updateProgress() === TRANSITION_COMPLETE) {
                // the previous transition is now complete
                this.lastTransition = nullTransition;
            }
        }

        // update any reachPoses
        for (pose in this.liveReachPoses) {
            // use independent timing for reachPoses
            this.liveReachPoses[pose].progress += (motion.deltaTime / this.liveReachPoses[pose].reachPoseParameters.duration);
            if (this.liveReachPoses[pose].progress >= 1) {
                // time to kill off this reach pose
                this.liveReachPoses.splice(pose, 1);
            }
        }

        // update transition progress
        this.filteredProgress = filter.bezier(this.progress, this.parameters.easingLower, this.parameters.easingUpper);
        return this.progress >= 1 ? TRANSITION_COMPLETE : false;
    };

    this.blendTranslations = function(frequencyTimeWheelPos, direction) {
        var lastTranslations = {x:0, y:0, z:0};
        var nextTranslations = animationOperations.calculateTranslations(this.nextAnimation,
                                                                         frequencyTimeWheelPos,
                                                                         direction);
        // are we blending with a previous, still live transition?
        if (this.lastTransition !== nullTransition) {
            lastTranslations = this.lastTransition.blendTranslations(this.lastFrequencyTimeWheelPos,
                                                                     this.lastDirection);
        } else {
            lastTranslations = animationOperations.calculateTranslations(this.lastAnimation,
                                                     this.lastFrequencyTimeWheelPos,
                                                     this.lastDirection);
        }

        // blend last / next translations
        nextTranslations = Vec3.multiply(this.filteredProgress, nextTranslations);
        lastTranslations = Vec3.multiply((1 - this.filteredProgress), lastTranslations);
        nextTranslations = Vec3.sum(nextTranslations, lastTranslations);

        if (this.liveReachPoses.length > 0) {
            for (pose in this.liveReachPoses) {
                var reachPoseStrength = this.liveReachPoses[pose].currentStrength();
                var poseTranslations = animationOperations.calculateTranslations(
                                                             this.liveReachPoses[pose].reachPoseDataFile,
                                                             frequencyTimeWheelPos,
                                                             direction);

                // can't use Vec3 operations here, as if x,y or z is zero, the reachPose should have no influence at all
                if (Math.abs(poseTranslations.x) > 0) {
                    nextTranslations.x = reachPoseStrength * poseTranslations.x + (1 - reachPoseStrength) * nextTranslations.x;
                }
                if (Math.abs(poseTranslations.y) > 0) {
                    nextTranslations.y = reachPoseStrength * poseTranslations.y + (1 - reachPoseStrength) * nextTranslations.y;
                }
                if (Math.abs(poseTranslations.z) > 0) {
                    nextTranslations.z = reachPoseStrength * poseTranslations.z + (1 - reachPoseStrength) * nextTranslations.z;
                }
            }
        }
        return nextTranslations;
    };

    this.blendRotations = function(jointName, frequencyTimeWheelPos, direction) {
        var lastRotations = {x:0, y:0, z:0};
        var nextRotations = animationOperations.calculateRotations(jointName,
                                               this.nextAnimation,
                                               frequencyTimeWheelPos,
                                               direction);

        // are we blending with a previous, still live transition?
        if (this.lastTransition !== nullTransition) {
            lastRotations = this.lastTransition.blendRotations(jointName,
                                                               this.lastFrequencyTimeWheelPos,
                                                               this.lastDirection);
        } else {
            lastRotations = animationOperations.calculateRotations(jointName,
                                               this.lastAnimation,
                                               this.lastFrequencyTimeWheelPos,
                                               this.lastDirection);
        }
        // blend last / next translations
        nextRotations = Vec3.multiply(this.filteredProgress, nextRotations);
        lastRotations = Vec3.multiply((1 - this.filteredProgress), lastRotations);
        nextRotations = Vec3.sum(nextRotations, lastRotations);

        // are there reachPoses defined for this transition?
        if (this.liveReachPoses.length > 0) {
            for (pose in this.liveReachPoses) {
                var reachPoseStrength = this.liveReachPoses[pose].currentStrength();
                var poseRotations = animationOperations.calculateRotations(jointName,
                                                       this.liveReachPoses[pose].reachPoseDataFile,
                                                       frequencyTimeWheelPos,
                                                       direction);

                // don't use Vec3 operations here, as if x,y or z is zero, the reach pose should have no influence at all
                if (Math.abs(poseRotations.x) > 0) {
                    nextRotations.x = reachPoseStrength * poseRotations.x + (1 - reachPoseStrength) * nextRotations.x;
                }
                if (Math.abs(poseRotations.y) > 0) {
                    nextRotations.y = reachPoseStrength * poseRotations.y + (1 - reachPoseStrength) * nextRotations.y;
                }
                if (Math.abs(poseRotations.z) > 0) {
                    nextRotations.z = reachPoseStrength * poseRotations.z + (1 - reachPoseStrength) * nextRotations.z;
                }
            }
        }
        return nextRotations;
    };
}; // end Transition constructor

// individual joint modifiers
FrequencyMultipliers = function(joint, direction) {
    // gather multipliers
    this.pitchFrequencyMultiplier = 1;
    this.yawFrequencyMultiplier = 1;
    this.rollFrequencyMultiplier = 1;
    this.swayFrequencyMultiplier = 1;
    this.bobFrequencyMultiplier = 1;
    this.thrustFrequencyMultiplier = 1;

    if (joint) {
        if (joint.pitchFrequencyMultiplier) {
            this.pitchFrequencyMultiplier = joint.pitchFrequencyMultiplier;
        }
        if (joint.yawFrequencyMultiplier) {
            this.yawFrequencyMultiplier = joint.yawFrequencyMultiplier;
        }
        if (joint.rollFrequencyMultiplier) {
            this.rollFrequencyMultiplier = joint.rollFrequencyMultiplier;
        }
        if (joint.swayFrequencyMultiplier) {
            this.swayFrequencyMultiplier = joint.swayFrequencyMultiplier;
        }
        if (joint.bobFrequencyMultiplier) {
            this.bobFrequencyMultiplier = joint.bobFrequencyMultiplier;
        }
        if (joint.thrustFrequencyMultiplier) {
            this.thrustFrequencyMultiplier = joint.thrustFrequencyMultiplier;
        }
    }
};