overte/scripts/simplifiedUI/clickToZoom/clickToZoom.js

509 lines
24 KiB
JavaScript

//
// Created by Luis Cuenca on 11/14/19
// Copyright 2019 High Fidelity, Inc.
//
//
// Distributed under the Apache License, Version 2.0.
// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html
//
(function() {
var ZoomStatus = {
"zoomingIn" : 0, // The camera is moving towards the selected entity
"zoomingOut" : 1, // The camera is moving away from the selected entity
"zoomedIn" : 2, // The camera is locked looking at the selected entity
"zoomedOut" : 4, // The camera is on its initial position and mode
"consumed" : 5 // The zooming loop has been completed
}
var FocusType = {
"avatar" : 0,
"entity" : 1
}
var EasingFunctions = {
easeInOutQuad: function (t) { return t<.5 ? 2*t*t : -1+(4-2*t)*t },
// accelerating from zero velocity
easeInCubic: function (t) { return t*t*t },
// decelerating to zero velocity
easeOutCubic: function (t) { return (--t)*t*t+1 },
// acceleration until halfway, then deceleration
easeInOutCubic: function (t) { return t<.5 ? 4*t*t*t : (t-1)*(2*t-2)*(2*t-2)+1 },
// accelerating from zero velocity
easeInQuart: function (t) { return t*t*t*t },
// decelerating to zero velocity
easeOutQuart: function (t) { return 1-(--t)*t*t*t },
// acceleration until halfway, then deceleration
easeInOutQuart: function (t) { return t<.5 ? 8*t*t*t*t : 1-8*(--t)*t*t*t },
// accelerating from zero velocity
easeInQuint: function (t) { return t*t*t*t*t },
// decelerating to zero velocity
easeOutQuint: function (t) { return 1+(--t)*t*t*t*t },
// acceleration until halfway, then deceleration
easeInOutQuint: function (t) { return t<.5 ? 16*t*t*t*t*t : 1+16*(--t)*t*t*t*t }
};
// Class that manages the zoom effect
var ZoomData = function(type, lookAt, focusNormal, focusDimensions, velocity, maxDuration) {
var self = this;
this.focusType = type;
this.lookAt = lookAt; // Look at 3d point in world coordinates
this.focusDimensions = focusDimensions; // 2d dimensions of the bounding box projection approximation of the selected entity
this.focusNormal = focusNormal; // The normal vector provided by the ray intersection used to initialize the zoom
this.velocity = velocity; // Max velocity of the camera zoom effect
this.maxDuration = maxDuration; // Max duration of the camera zoom effect
this.initialPos = Camera.getPosition();
this.initialRot = Camera.getOrientation();
this.interpolatedPos = this.initialPos;
this.interpolatedRot = this.initialRot;
this.initialMode = Camera.mode;
this.initialOffset = Vec3.distance(self.initialPos, MyAvatar.getDefaultEyePosition()); // Save the offset distance from the camera to the avatar eyes (boom)
this.finalPos = Vec3.ZERO;
this.finalRot = Quat.IDENTITY;
this.direction = Vec3.ZERO; // Direction vector for the camera path
this.distance = Vec3.ZERO; // Total distance that the camera needs to cover to zoom in along the path
this.totalTime = 0.0; // Total time needed to cover the zoom path
this.elapsedTime = 0.0;
var MAX_ZOOM_IN_AMOUNT = 0.6; // Maximum percentage of camera proximity to the selected entity from the look at point
var MAX_ZOOM_OUT_AMOUNT = 0.2; // Maximum percentage of camera remoteness to the selected entity from the look at point
this.maxZoomInAmount = MAX_ZOOM_IN_AMOUNT;
this.maxZoomOutAmount = MAX_ZOOM_OUT_AMOUNT;
this.currentZoomAmount = 0.0;
this.zoomPanOffset = {x: 0.5 * Window.innerWidth, y: 0.5 * Window.innerHeight};
this.zoomPanDelta = {x: 0.0, y: 0.0};
this.status = ZoomStatus.zoomedOut;
var OWN_CAMERA_CHANGE_MAX_FRAMES = 30;
this.ownCameraChangeElapseTime = 0.0; // Variables used to track if the camera mode change is triggered by this script
this.ownCameraChange = false; // or by user input, since the later needs to abort the zoom loop
// Function called when the user release the mouse while panning the camera when using the secondary zoom
this.applyZoomPan = function() {
self.zoomPanOffset.x += self.zoomPanDelta.x;
self.zoomPanOffset.y += self.zoomPanDelta.y;
}
// Function called when the user moves the mouse while panning the camera when using the secondary zoom
this.setZoomPanDelta = function(x, y) {
self.zoomPanDelta.x = x;
self.zoomPanDelta.y = y;
var totalX = self.zoomPanOffset.x + self.zoomPanDelta.x;
var totalY = self.zoomPanOffset.y + self.zoomPanDelta.y;
totalX = Math.min(Math.max(totalX, 0.0), Window.innerWidth);
totalY = Math.min(Math.max(totalY, 0.0), Window.innerHeight);
self.zoomPanDelta.x = totalX - self.zoomPanOffset.x;
self.zoomPanDelta.y = totalY - self.zoomPanOffset.y;
self.updateSuperPan(totalX, totalY);
}
// This function computes the correct distance to the selected entity so it ends up fitted on the screen
this.getFocusDistance = function(zoomDims) {
var objAspect = zoomDims.x / zoomDims.y;
var camAspect = Camera.frustum.aspectRatio;
var m = 0.0;
if (objAspect < camAspect) {
m = zoomDims.y;
} else {
m = zoomDims.x / camAspect;
}
var DEGREE_TO_RADIAN = 0.0174533;
var fov = DEGREE_TO_RADIAN * Camera.frustum.fieldOfView;
return (0.5 * m) / Math.tan(0.5 * fov);
}
// This function computes the final camera position and rotation
this.computeFinalForm = function() {
self.finalPos = Vec3.sum(self.lookAt, Vec3.multiply(self.getFocusDistance(self.focusDimensions), self.focusNormal));
self.finalRot = Quat.lookAtSimple(self.finalPos, self.lookAt);
}
// Function that computes the time and path information for the camera zoom in
this.computeRouteIn = function() {
var railVector = Vec3.subtract(self.finalPos, self.initialPos);
self.direction = Vec3.normalize(railVector);
self.distance = Vec3.length(railVector);
self.totalTime = self.distance / self.velocity;
self.totalTime = self.totalTime > self.maxDuration ? self.maxDuration : self.totalTime;
}
// Function that computes the time and path information for the camera zoom out
// Since the camera orientation and position gets reset when changing camera modes,
// the camera path while zooming out is different from the path zooming in.
this.computeRouteOut = function() {
self.finalPos = Camera.getPosition();
var camOffset = Vec3.ZERO;
var myAvatarRotation = MyAvatar.orientation;
if (self.initialMode.indexOf("first") != -1) {
self.initialRot = myAvatarRotation;
} else {
var lookAtPoint = MyAvatar.getDefaultEyePosition();
var lookFromSign = self.initialMode.indexOf("selfie") != -1 ? 1 : -1;
var lookFromPoint = Vec3.sum(lookAtPoint, Vec3.multiply(self.initialOffset * lookFromSign, Quat.getFront(myAvatarRotation)));
self.initialRot = Quat.lookAtSimple(lookFromPoint, lookAtPoint);
self.initialPos = lookFromPoint;
}
self.computeRouteIn();
}
// This function initiate the camera zoom in
this.initZoomIn = function() {
if (self.status === ZoomStatus.zoomedOut) {
self.computeRouteIn();
self.status = ZoomStatus.zoomingIn;
self.changeCameraMode("independent");
self.elapsedTime = 0.0;
}
}
// This function initiate the camera zoom in
this.initZoomOut = function() {
if (self.status === ZoomStatus.zoomedIn) {
self.computeRouteOut();
self.status = ZoomStatus.zoomingOut;
self.changeCameraMode("independent");
self.elapsedTime = 0.0;
}
}
// This function checks if the zoom loop needs update
this.needsUpdate = function() {
return self.status === ZoomStatus.zoomingIn || self.status === ZoomStatus.zoomingOut;
}
// This function connects to the update signal only when zoom in or zoom out is needed
this.updateZoom = function(deltaTime) {
if (self.ownCameraChange) {
self.ownCameraChange = self.ownCameraChangeElapseTime < OWN_CAMERA_CHANGE_MAX_FRAMES * deltaTime;
self.ownCameraChangeElapseTime += deltaTime;
}
if (!self.needsUpdate) {
return;
}
if (self.elapsedTime < self.totalTime) {
var ratio = EasingFunctions.easeInOutQuart(self.elapsedTime / self.totalTime);
if (self.status === ZoomStatus.zoomingIn) {
var curDist = self.distance * ratio;
var addition = Vec3.multiply(curDist, self.direction);
self.interpolatedPos = Vec3.sum(self.initialPos, addition);
self.interpolatedRot = Quat.mix(self.initialRot, self.finalRot, ratio);
} else if (self.status === ZoomStatus.zoomingOut) {
self.interpolatedPos = Vec3.sum(self.finalPos, Vec3.multiply(self.distance * ratio, Vec3.multiply(-1, self.direction)));
self.interpolatedRot = Quat.mix(self.finalRot, self.initialRot, ratio);
}
self.elapsedTime += deltaTime;
Camera.setPosition(self.interpolatedPos);
Camera.setOrientation(self.interpolatedRot);
} else {
Camera.setPosition(self.finalPos);
Camera.setOrientation(self.finalRot);
if (self.status === ZoomStatus.zoomingIn) {
self.status = ZoomStatus.zoomedIn;
} else if (self.status === ZoomStatus.zoomingOut) {
self.status = ZoomStatus.consumed;
self.changeCameraMode(self.initialMode);
}
}
}
// This function recomputes the camera distance to the selected item in order to fit it on the screen
this.resetZoomAspect = function() {
self.computeFinalForm();
self.computeRouteIn();
Camera.setPosition(self.finalPos);
}
// This function updates the secondary zoom distance to the selected entity according to the mouse wheel event
this.updateSuperZoom = function(delta) {
var ZOOM_STEP = 0.1;
self.currentZoomAmount = self.currentZoomAmount + (delta < 0.0 ? -1 : 1) * ZOOM_STEP;
self.currentZoomAmount = Math.min(Math.max(self.currentZoomAmount, -self.maxZoomOutAmount), self.maxZoomInAmount);
self.updateSuperPan(self.zoomPanOffset.x, self.zoomPanOffset.y);
}
// This function updates the secondary zoom panning according to the mouse move event
this.updateSuperPan = function(x, y) {
var zoomOffset = Vec3.multiply(self.currentZoomAmount, Vec3.subtract(self.lookAt, self.finalPos));
var xRatio = 0.5 - x / Window.innerWidth;
var yRatio = 0.5 - y / Window.innerHeight;
var cameraOrientation = Camera.getOrientation();
var cameraY = Quat.getUp(cameraOrientation);
var cameraX = Vec3.multiply(-1, Quat.getRight(cameraOrientation));
var xOffset = Vec3.multiply(xRatio * self.focusDimensions.x, cameraX);
var yOffset = Vec3.multiply(yRatio * self.focusDimensions.y, cameraY);
zoomOffset = Vec3.sum(zoomOffset, xOffset);
zoomOffset = Vec3.sum(zoomOffset, yOffset);
Camera.setPosition(Vec3.sum(self.finalPos, zoomOffset));
}
// This function aborts the zoom loop when the user changes the camera mode
this.abort = function() {
self.changeCameraMode(self.initialMode);
}
// This function changes the camera mode
this.changeCameraMode = function(mode) {
self.ownCameraChange = true;
self.ownCameraChangeElapseTime = 0.0;
Camera.mode = mode;
}
this.computeFinalForm();
}
// Class that manages the mouse events and computes the 3d structure data of the selected entity or avatar
var ZoomOnAnything = function() {
var self = this;
this.zoomEntityData; // The selected entity's data
this.zoomCameraPos;
var ZOOM_MAX_VELOCITY = 15.0; // meters per second
var ZOOM_MAX_DURATION = 1.0; // seconds
this.zoomDelta = {x: 0.0, y: 0.0}; // mouse move delta while panning
this.isPanning = false;
this.screenPointRef = {x: 0.0, y: 0.0}; // Screen pixels reference used to compute the zoomDelta
this.connected = false;
// This function estimates the projection axis and the dimensions along that axis of the selected entity
this.getEntityDimsFromNormal = function(dims, rot, normal) {
var zoomXNormal = Vec3.multiplyQbyV(rot, Vec3.UNIT_X);
var zoomYNormal = Vec3.multiplyQbyV(rot, Vec3.UNIT_Y);
var zoomZNormal = Vec3.multiplyQbyV(rot, Vec3.UNIT_Z);
var affinities = [
{axis: "x", normal: zoomXNormal, affin: Math.abs(Vec3.dot(zoomXNormal, normal)), dims: {x: dims.z, y: dims.y}},
{axis: "y", normal: zoomYNormal, affin: Math.abs(Vec3.dot(zoomYNormal, normal)), dims: {x: dims.x, y: dims.z}},
{axis: "z", normal: zoomZNormal, affin: Math.abs(Vec3.dot(zoomZNormal, normal)), dims: {x: dims.x, y: dims.y}}
];
affinities.sort(function(a, b) {
return b.affin - a.affin;
});
return affinities[0];
}
// This function estimates the appropriate focus point when the selected entity is an avatar
this.getAvatarFocusPoint = function(avatar) {
var rEyeIndex = avatar.getJointIndex("RightEye");
var lEyeIndex = avatar.getJointIndex("LeftEye");
var headIndex = avatar.getJointIndex("Head");
var focusPoint = Vec3.ZERO;
var validPoint = false;
var count = 0;
if (rEyeIndex != -1) {
focusPoint = Vec3.sum(focusPoint, avatar.getJointPosition(rEyeIndex));
validPoint = true;
count++;
}
if (lEyeIndex != -1) {
var leftEyePos = avatar.getJointPosition(lEyeIndex);
var NORMAL_EYE_DISTANCE = 0.1;
focusPoint = Vec3.sum(focusPoint, leftEyePos);
validPoint = true;
count++;
}
if (headIndex != -1) {
focusPoint = Vec3.sum(focusPoint, avatar.getJointPosition(headIndex));
validPoint = true;
count++;
}
if (!validPoint) {
focusPoint = avatar.getJointPosition("Hips");
count++;
}
return Vec3.multiply(1.0/count, focusPoint);
}
// This function sets the data needed to zoom in on an avatar
this.getZoomDataFromAvatar = function(avatarID, skinToBoneDist, zoomVelocity, maxDuration) {
var headDiam = 2.0 * skinToBoneDist; // Head diameter is estimated based on the distance to the bone
headDiam = headDiam < 0.5 ? 0.5 : headDiam;
var avatar = AvatarList.getAvatar(avatarID);
var focusPoint = self.getAvatarFocusPoint(avatar);
var focusDims = {x: headDiam, y: headDiam};
var focusNormal = Quat.getFront(avatar.orientation);
var zoomData = new ZoomData(FocusType.avatar, focusPoint, focusNormal, focusDims, zoomVelocity, maxDuration);
return zoomData;
}
// This function sets the data needed to zoom in on an entity
this.getZoomDataFromEntity = function(intersection, objectProps, zoomVelocity, maxDuration) {
var position = objectProps.position;
var dimensions = objectProps.dimensions;
var rotation = objectProps.rotation;
var focusNormal = intersection.surfaceNormal;
var dimsResult = self.getEntityDimsFromNormal(dimensions, rotation, focusNormal);
var focusDims = dimsResult.dims;
var focusDepth = Vec3.dot(Vec3.subtract(intersection.intersection, position), dimsResult.normal);
var newPosition = Vec3.sum(position, Vec3.multiply(focusDepth, dimsResult.normal));
var zoomData = new ZoomData(FocusType.entity, newPosition, focusNormal, focusDims, zoomVelocity, maxDuration);
return zoomData;
}
// This function initiate the zoom loop on the selected entity
this.zoomOnEntity = function(intersection, objectProps) {
self.zoomEntityData = self.getZoomDataFromEntity(intersection, objectProps, ZOOM_MAX_VELOCITY, ZOOM_MAX_DURATION);
self.zoomEntityData.initZoomIn();
}
// This function initiates the zoom loop on the selected avatar
this.zoomOnAvatar = function(avatarID, skinToBoneDist) {
self.zoomEntityData = self.getZoomDataFromAvatar(avatarID, skinToBoneDist, ZOOM_MAX_VELOCITY, ZOOM_MAX_DURATION);
self.zoomEntityData.initZoomIn();
}
// This function connects to the Script.update signal
this.connect = function() {
self.connected = true;
Script.update.connect(self.updateZoom);
}
// This function disconnects to the Script.update signal
this.disconnect = function() {
self.connected = false;
Script.update.disconnect(self.updateZoom);
}
// This function get connected to the Script.update signal
this.updateZoom = function(deltaTime) {
if (self.zoomEntityData) {
if (self.zoomEntityData.needsUpdate()) {
self.zoomEntityData.updateZoom(deltaTime);
if (self.zoomEntityData.status === ZoomStatus.consumed) {
self.disconnect();
self.zoomEntityData = undefined;
}
} else {
self.disconnect();
}
} else if (self.connected) {
self.disconnect();
}
}
// This function listen to the mouse double click that evaluates the selected entity and the intersection data that initiates the zoom loop
this.mouseDoublePressEvent = function(event) {
if (event.isLeftButton) {
self.connect();
if (!self.zoomEntityData) {
var pickRay = Camera.computePickRay(event.x, event.y);
var intersection = AvatarManager.findRayIntersection({origin: pickRay.origin, direction: pickRay.direction}, [], [MyAvatar.sessionUUID], false);
if (!intersection.intersects) {
intersection = Entities.findRayIntersection({origin: pickRay.origin, direction: pickRay.direction}, true);
var entityProps = Entities.getEntityProperties(intersection.entityID);
if (entityProps.type === "Shape") {
var FIND_SHAPES_DISTANCE = 10.0;
var shapes = Entities.findEntitiesByType("Shape", intersection.intersection, FIND_SHAPES_DISTANCE);
intersection = Entities.findRayIntersection({origin: pickRay.origin, direction: pickRay.direction}, true, [], shapes);
entityProps = Entities.getEntityProperties(intersection.entityID);
}
if (!entityProps.dimensions) {
return;
}
self.zoomOnEntity(intersection, entityProps);
} else {
var avatar = AvatarList.getAvatar(intersection.avatarID);
var skinToBoneDist = Vec3.distance(intersection.intersection, avatar.getJointPosition(intersection.jointIndex));
self.zoomOnAvatar(intersection.avatarID, skinToBoneDist);
}
} else if (!self.zoomEntityData.needsUpdate()){
self.zoomEntityData.initZoomOut();
}
}
}
// This function listen to the mouse press event to initiate panning while on secondary zoom
this.mousePressEvent = function(event) {
if (event.isRightButton) {
self.isPanning = true;
self.screenPointRef = {x: event.x, y: event.y};
}
}
// This function listen to the mouse release event to apply the pan delta while on secondary zoom
this.mouseReleaseEvent = function(event) {
if (event.isRightButton) {
if (self.zoomEntityData) {
self.zoomEntityData.applyZoomPan();
self.isPanning = false;
self.screenPointRef = {x: 0, y: 0};
}
}
}
// This function listen to the mouse move event to modify the pan delta coordenates while on secondary zoom
this.mouseMoveEvent = function(event) {
if (event.isRightButton) {
if (self.isPanning && self.zoomEntityData) {
self.zoomEntityData.setZoomPanDelta(event.x - self.screenPointRef.x, event.y - self.screenPointRef.y);
}
}
}
// This function listen to the mouse wheel event to modify the ammount of secondary zoom
this.mouseWheel = function(event) {
if (self.zoomEntityData) {
self.zoomEntityData.updateSuperZoom(event.delta);
}
}
// This function aborts the zoom loop
this.abort = function() {
self.zoomEntityData.abort();
self.zoomEntityData = undefined;
if (self.connected) {
self.disconnect();
}
}
// This function refits the selected entity on the screen when the screen dimensions change
this.geometryChanged = function() {
if (self.zoomEntityData !== undefined){
self.zoomEntityData.resetZoomAspect();
}
}
// This function aborts the zoom loop if the camera mode is set by the user
this.modeUpdated = function(mode) {
if (self.zoomEntityData && !self.zoomEntityData.ownCameraChange) {
self.abort();
}
}
// This function initiate the connections
this.init = function() {
// Connect signals
Window.geometryChanged.connect(self.geometryChanged);
Camera.modeUpdated.connect(self.modeUpdated);
Controller.mousePressEvent.connect(self.mousePressEvent);
Controller.mouseDoublePressEvent.connect(self.mouseDoublePressEvent);
Controller.mouseMoveEvent.connect(self.mouseMoveEvent);
Controller.mouseReleaseEvent.connect(self.mouseReleaseEvent);
Controller.wheelEvent.connect(self.mouseWheel);
}
// This function finish the connections
this.scriptEnding = function() {
// Disconnect on exit
Window.geometryChanged.disconnect(self.geometryChanged);
Camera.modeUpdated.disconnect(self.modeUpdated);
Controller.mousePressEvent.disconnect(self.mousePressEvent);
Controller.mouseDoublePressEvent.disconnect(self.mouseDoublePressEvent);
Controller.mouseMoveEvent.disconnect(self.mouseMoveEvent);
Controller.mouseReleaseEvent.disconnect(self.mouseReleaseEvent);
Controller.wheelEvent.disconnect(self.mouseWheel);
if (self.zoomEntityData) {
self.abort();
}
}
}
var zoomOE = new ZoomOnAnything();
zoomOE.init();
Script.scriptEnding.connect(zoomOE.scriptEnding);
})();