"use strict"; // grab.js // examples // // Created by Eric Levin on May 1, 2015 // Copyright 2015 High Fidelity, Inc. // // Grab's physically moveable entities with the mouse, by applying a spring force. // // Updated November 22, 2016 by Philip Rosedale: Add distance attenuation of grab effect // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // /* global MyAvatar, Entities, Script, Camera, Vec3, Reticle, Overlays, getEntityCustomData, Messages, Quat, Controller, isInEditMode, HMD entityIsGrabbable, Pointers, PickType RayPick*/ (function() { // BEGIN LOCAL_SCOPE Script.include("/~/system/libraries/utils.js"); Script.include("/~/system/libraries/controllerDispatcherUtils.js"); var MAX_SOLID_ANGLE = 0.01; // objects that appear smaller than this can't be grabbed var DELAY_FOR_30HZ = 33; // milliseconds var ZERO_VEC3 = { x: 0, y: 0, z: 0 }; var IDENTITY_QUAT = { x: 0, y: 0, z: 0, w: 0 }; var GRABBABLE_DATA_KEY = "grabbableKey"; // shared with handControllerGrab.js var DEFAULT_GRABBABLE_DATA = { grabbable: true, invertSolidWhileHeld: false }; var ACTION_TTL = 10; // seconds function getTag() { return "grab-" + MyAvatar.sessionUUID; } var DISTANCE_HOLDING_ACTION_TIMEFRAME = 0.1; // how quickly objects move to their new position var DISTANCE_HOLDING_UNITY_MASS = 1200; // The mass at which the distance holding action timeframe is unmodified var DISTANCE_HOLDING_UNITY_DISTANCE = 6; // The distance at which the distance holding action timeframe is unmodified function distanceGrabTimescale(mass, distance) { var timeScale = DISTANCE_HOLDING_ACTION_TIMEFRAME * mass / DISTANCE_HOLDING_UNITY_MASS * distance / DISTANCE_HOLDING_UNITY_DISTANCE; if (timeScale < DISTANCE_HOLDING_ACTION_TIMEFRAME) { timeScale = DISTANCE_HOLDING_ACTION_TIMEFRAME; } return timeScale; } function getMass(dimensions, density) { return (dimensions.x * dimensions.y * dimensions.z) * density; } function entityIsGrabbedByOther(entityID) { // by convention, a distance grab sets the tag of its action to be grab-*owner-session-id*. var actionIDs = Entities.getActionIDs(entityID); for (var actionIndex = 0; actionIndex < actionIDs.length; actionIndex++) { var actionID = actionIDs[actionIndex]; var actionArguments = Entities.getActionArguments(entityID, actionID); var tag = actionArguments.tag; if (tag == getTag()) { // we see a grab-*uuid* shaped tag, but it's our tag, so that's okay. continue; } if (tag.slice(0, 5) == "grab-") { // we see a grab-*uuid* shaped tag and it's not ours, so someone else is grabbing it. return true; } } return false; } // helper function function mouseIntersectionWithPlane(pointOnPlane, planeNormal, event, maxDistance) { var cameraPosition = Camera.getPosition(); var localPointOnPlane = Vec3.subtract(pointOnPlane, cameraPosition); var distanceFromPlane = Vec3.dot(localPointOnPlane, planeNormal); var MIN_DISTANCE_FROM_PLANE = 0.001; if (Math.abs(distanceFromPlane) < MIN_DISTANCE_FROM_PLANE) { // camera is touching the plane return pointOnPlane; } var pickRay = Camera.computePickRay(event.x, event.y); var dirDotNorm = Vec3.dot(pickRay.direction, planeNormal); var MIN_RAY_PLANE_DOT = 0.00001; var localIntersection; var useMaxForwardGrab = false; if (Math.abs(dirDotNorm) > MIN_RAY_PLANE_DOT) { var distanceToIntersection = distanceFromPlane / dirDotNorm; if (distanceToIntersection > 0 && distanceToIntersection < maxDistance) { // ray points into the plane localIntersection = Vec3.multiply(pickRay.direction, distanceFromPlane / dirDotNorm); } else { // ray intersects BEHIND the camera or else very far away // so we clamp the grab point to be the maximum forward position useMaxForwardGrab = true; } } else { // ray points perpendicular to grab plane // so we map the grab point to the maximum forward position useMaxForwardGrab = true; } if (useMaxForwardGrab) { // we re-route the intersection to be in front at max distance. var rayDirection = Vec3.subtract(pickRay.direction, Vec3.multiply(planeNormal, dirDotNorm)); rayDirection = Vec3.normalize(rayDirection); localIntersection = Vec3.multiply(rayDirection, maxDistance); localIntersection = Vec3.sum(localIntersection, Vec3.multiply(planeNormal, distanceFromPlane)); } var worldIntersection = Vec3.sum(cameraPosition, localIntersection); return worldIntersection; } // Mouse class stores mouse click and drag info function Mouse() { this.current = { x: 0, y: 0 }; this.previous = { x: 0, y: 0 }; this.rotateStart = { x: 0, y: 0 }; this.cursorRestore = { x: 0, y: 0 }; } Mouse.prototype.startDrag = function(position) { this.current = { x: position.x, y: position.y }; this.startRotateDrag(); }; Mouse.prototype.updateDrag = function(position) { this.current = { x: position.x, y: position.y }; }; Mouse.prototype.startRotateDrag = function() { this.previous = { x: this.current.x, y: this.current.y }; this.rotateStart = { x: this.current.x, y: this.current.y }; this.cursorRestore = Reticle.getPosition(); }; Mouse.prototype.getDrag = function() { var delta = { x: this.current.x - this.previous.x, y: this.current.y - this.previous.y }; this.previous = { x: this.current.x, y: this.current.y }; return delta; }; Mouse.prototype.restoreRotateCursor = function() { Reticle.setPosition(this.cursorRestore); this.current = { x: this.rotateStart.x, y: this.rotateStart.y }; }; var mouse = new Mouse(); var beacon = { type: "cube", dimensions: { x: 0.01, y: 0, z: 0.01 }, color: { red: 200, green: 200, blue: 200 }, alpha: 1, solid: true, ignoreRayIntersection: true, visible: true }; // TODO: play sounds again when we aren't leaking AudioInjector threads // var grabSound = SoundCache.getSound("https://hifi-public.s3.amazonaws.com/eric/sounds/CloseClamp.wav"); // var releaseSound = SoundCache.getSound("https://hifi-public.s3.amazonaws.com/eric/sounds/ReleaseClamp.wav"); // var VOLUME = 0.0; // Grabber class stores and computes info for grab behavior function Grabber() { this.isGrabbing = false; this.entityID = null; this.actionID = null; this.startPosition = ZERO_VEC3; this.lastRotation = IDENTITY_QUAT; this.currentPosition = ZERO_VEC3; this.planeNormal = ZERO_VEC3; // maxDistance is a function of the size of the object. this.maxDistance = 0; // mode defines the degrees of freedom of the grab target positions // relative to startPosition options include: // xzPlane (default) // verticalCylinder (SHIFT) // rotate (CONTROL) this.mode = "xzplane"; // offset allows the user to grab an object off-center. It points from the object's center // to the point where the ray intersects the grab plane (at the moment the grab is initiated). // Future target positions of the ray intersection are on the same plane, and the offset is subtracted // to compute the target position of the object's center. this.offset = { x: 0, y: 0, z: 0 }; this.targetPosition = null; this.targetRotation = null; this.liftKey = false; // SHIFT this.rotateKey = false; // CONTROL this.mouseRayOverlays = RayPick.createRayPick({ joint: "Mouse", filter: Picks.PICK_OVERLAYS, enabled: true }); RayPick.setIncludeItems(this.mouseRayOverlays, [HMD.tabletID, HMD.tabletScreenID, HMD.homeButtonID]); var renderStates = [{name: "grabbed", end: beacon}]; this.mouseRayEntities = Pointers.createPointer(PickType.Ray, { joint: "Mouse", filter: Picks.PICK_ENTITIES, faceAvatar: true, scaleWithAvatar: true, enabled: true, renderStates: renderStates }); } Grabber.prototype.computeNewGrabPlane = function() { if (!this.isGrabbing) { return; } var modeWasRotate = (this.mode == "rotate"); this.mode = "xzPlane"; this.planeNormal = { x: 0, y: 1, z: 0 }; if (this.rotateKey) { this.mode = "rotate"; mouse.startRotateDrag(); } else { if (modeWasRotate) { // we reset the mouse screen position whenever we stop rotating mouse.restoreRotateCursor(); } if (this.liftKey) { this.mode = "verticalCylinder"; // NOTE: during verticalCylinder mode a new planeNormal will be computed each move } } this.pointOnPlane = Vec3.sum(this.currentPosition, this.offset); var xzOffset = Vec3.subtract(this.pointOnPlane, Camera.getPosition()); xzOffset.y = 0; this.xzDistanceToGrab = Vec3.length(xzOffset); }; Grabber.prototype.pressEvent = function(event) { if (isInEditMode() || HMD.active) { return; } if (event.isLeftButton !== true || event.isRightButton === true || event.isMiddleButton === true) { return; } if (event.isAlt || event.isMeta) { return; } if (Overlays.getOverlayAtPoint(Reticle.position) > 0) { // the mouse is pointing at an overlay; don't look for entities underneath the overlay. return; } var overlayResult = RayPick.getPrevRayPickResult(this.mouseRayOverlays); if (overlayResult.type != Picks.INTERSECTED_NONE) { return; } var pickResults = Pointers.getPrevPickResult(this.mouseRayEntities); if (pickResults.type == Picks.INTERSECTED_NONE) { Pointers.setRenderState(this.mouseRayEntities, ""); return; } var props = Entities.getEntityProperties(pickResults.objectID, ["dynamic", "userData", "locked", "type"]); var isDynamic = props.dynamic; var isGrabbable = props.grabbable; if (!entityIsGrabbable(props)) { // only grab grabbable objects return; } var grabbableData = getEntityCustomData(GRABBABLE_DATA_KEY, pickResults.objectID, DEFAULT_GRABBABLE_DATA); if (grabbableData.grabbable === false) { return; } Pointers.setRenderState(this.mouseRayEntities, "grabbed"); Pointers.setLockEndUUID(this.mouseRayEntities, pickResults.objectID, false); mouse.startDrag(event); var clickedEntity = pickResults.objectID; var entityProperties = Entities.getEntityProperties(clickedEntity); this.startPosition = entityProperties.position; this.lastRotation = entityProperties.rotation; this.madeDynamic = false; var cameraPosition = Camera.getPosition(); var objectBoundingDiameter = Vec3.length(entityProperties.dimensions); beacon.dimensions.y = objectBoundingDiameter; Pointers.editRenderState(this.mouseRayEntities, "grabbed", {end: beacon}); this.maxDistance = objectBoundingDiameter / MAX_SOLID_ANGLE; if (Vec3.distance(this.startPosition, cameraPosition) > this.maxDistance) { // don't allow grabs of things far away return; } if (entityIsGrabbable(props) && !isDynamic) { entityProperties.dynamic = true; Entities.editEntity(clickedEntity, entityProperties); this.madeDynamic = true; } // this.activateEntity(clickedEntity, entityProperties); this.isGrabbing = true; this.entityID = clickedEntity; this.currentPosition = entityProperties.position; this.targetPosition = { x: this.startPosition.x, y: this.startPosition.y, z: this.startPosition.z }; // compute the grab point var pickRay = Camera.computePickRay(event.x, event.y); var nearestPoint = Vec3.subtract(this.startPosition, cameraPosition); var distanceToGrab = Vec3.dot(nearestPoint, pickRay.direction); nearestPoint = Vec3.multiply(distanceToGrab, pickRay.direction); this.pointOnPlane = Vec3.sum(cameraPosition, nearestPoint); // compute the grab offset (points from object center to point of grab) this.offset = Vec3.subtract(this.pointOnPlane, this.startPosition); this.computeNewGrabPlane(); if (!entityIsGrabbedByOther(this.entityID)) { this.moveEvent(event); } var args = "mouse"; Entities.callEntityMethod(this.entityID, "startDistanceGrab", args); Messages.sendMessage('Hifi-Object-Manipulation', JSON.stringify({ action: 'grab', grabbedEntity: this.entityID })); // TODO: play sounds again when we aren't leaking AudioInjector threads //Audio.playSound(grabSound, { position: entityProperties.position, volume: VOLUME }); }; Grabber.prototype.releaseEvent = function(event) { if ((event.isLeftButton!==true || event.isRightButton===true || event.isMiddleButton===true) && !HMD.active) { return; } if (this.moveEventTimer) { Script.clearTimeout(this.moveEventTimer); this.moveEventTimer = null; } if (this.isGrabbing) { // this.deactivateEntity(this.entityID); this.isGrabbing = false; if (this.actionID) { Entities.deleteAction(this.entityID, this.actionID); } if (this.madeDynamic) { var entityProps = {}; entityProps.dynamic = false; entityProps.localVelocity = {x: 0, y: 0, z: 0}; Entities.editEntity(this.entityID, entityProps); } this.actionID = null; Pointers.setRenderState(this.mouseRayEntities, ""); var args = "mouse"; Entities.callEntityMethod(this.entityID, "releaseGrab", args); Messages.sendMessage('Hifi-Object-Manipulation', JSON.stringify({ action: 'release', grabbedEntity: this.entityID, joint: "mouse" })); // TODO: play sounds again when we aren't leaking AudioInjector threads //Audio.playSound(releaseSound, { position: entityProperties.position, volume: VOLUME }); } }; Grabber.prototype.scheduleMouseMoveProcessor = function(event) { var _this = this; if (!this.moveEventTimer) { this.moveEventTimer = Script.setTimeout(function() { _this.moveEventProcess(); }, DELAY_FOR_30HZ); } }; Grabber.prototype.moveEvent = function(event) { // during the handling of the event, do as little as possible. We save the updated mouse position, // and start a timer to react to the change. If more changes arrive before the timer fires, only // the last update will be considered. This is done to avoid backing-up Qt's event queue. if (!this.isGrabbing || HMD.active) { return; } mouse.updateDrag(event); this.scheduleMouseMoveProcessor(); }; Grabber.prototype.moveEventProcess = function() { this.moveEventTimer = null; // see if something added/restored gravity var entityProperties = Entities.getEntityProperties(this.entityID); if (!entityProperties || !entityProperties.gravity || HMD.active) { return; } if (Vec3.length(entityProperties.gravity) !== 0.0) { this.originalGravity = entityProperties.gravity; } this.currentPosition = entityProperties.position; this.mass = getMass(entityProperties.dimensions, entityProperties.density); var cameraPosition = Camera.getPosition(); var actionArgs = { tag: getTag(), ttl: ACTION_TTL }; if (this.mode === "rotate") { var drag = mouse.getDrag(); var orientation = Camera.getOrientation(); var dragOffset = Vec3.multiply(drag.x, Quat.getRight(orientation)); dragOffset = Vec3.sum(dragOffset, Vec3.multiply(-drag.y, Quat.getUp(orientation))); var axis = Vec3.cross(dragOffset, Quat.getForward(orientation)); axis = Vec3.normalize(axis); var ROTATE_STRENGTH = 0.4; // magic number tuned by hand var angle = ROTATE_STRENGTH * Math.sqrt((drag.x * drag.x) + (drag.y * drag.y)); var deltaQ = Quat.angleAxis(angle, axis); // var qZero = entityProperties.rotation; //var qZero = this.lastRotation; this.lastRotation = Quat.multiply(deltaQ, this.lastRotation); var distanceToCameraR = Vec3.length(Vec3.subtract(this.currentPosition, cameraPosition)); var angularTimeScale = distanceGrabTimescale(this.mass, distanceToCameraR); actionArgs = { targetRotation: this.lastRotation, angularTimeScale: angularTimeScale, tag: getTag(), ttl: ACTION_TTL }; } else { var newPointOnPlane; if (this.mode === "verticalCylinder") { // for this mode we recompute the plane based on current Camera var planeNormal = Quat.getForward(Camera.getOrientation()); planeNormal.y = 0; planeNormal = Vec3.normalize(planeNormal); var pointOnCylinder = Vec3.multiply(planeNormal, this.xzDistanceToGrab); pointOnCylinder = Vec3.sum(Camera.getPosition(), pointOnCylinder); this.pointOnPlane = mouseIntersectionWithPlane(pointOnCylinder, planeNormal, mouse.current, this.maxDistance); newPointOnPlane = { x: this.pointOnPlane.x, y: this.pointOnPlane.y, z: this.pointOnPlane.z }; } else { newPointOnPlane = mouseIntersectionWithPlane( this.pointOnPlane, this.planeNormal, mouse.current, this.maxDistance); var relativePosition = Vec3.subtract(newPointOnPlane, cameraPosition); var distance = Vec3.length(relativePosition); if (distance > this.maxDistance) { // clamp distance relativePosition = Vec3.multiply(relativePosition, this.maxDistance / distance); newPointOnPlane = Vec3.sum(relativePosition, cameraPosition); } } this.targetPosition = Vec3.subtract(newPointOnPlane, this.offset); var distanceToCameraL = Vec3.length(Vec3.subtract(this.targetPosition, cameraPosition)); var linearTimeScale = distanceGrabTimescale(this.mass, distanceToCameraL); actionArgs = { targetPosition: this.targetPosition, linearTimeScale: linearTimeScale, tag: getTag(), ttl: ACTION_TTL }; } if (!this.actionID) { if (!entityIsGrabbedByOther(this.entityID)) { this.actionID = Entities.addAction("far-grab", this.entityID, actionArgs); } } else { Entities.updateAction(this.entityID, this.actionID, actionArgs); } this.scheduleMouseMoveProcessor(); }; Grabber.prototype.keyReleaseEvent = function(event) { if (event.text === "SHIFT") { this.liftKey = false; } if (event.text === "CONTROL") { this.rotateKey = false; } this.computeNewGrabPlane(); }; Grabber.prototype.keyPressEvent = function(event) { if (event.text === "SHIFT") { this.liftKey = true; } if (event.text === "CONTROL") { this.rotateKey = true; } this.computeNewGrabPlane(); }; Grabber.prototype.cleanup = function() { Pointers.removePointer(this.mouseRayEntities); RayPick.removeRayPick(this.mouseRayOverlays); }; var grabber = new Grabber(); function pressEvent(event) { grabber.pressEvent(event); } function moveEvent(event) { grabber.moveEvent(event); } function releaseEvent(event) { grabber.releaseEvent(event); } function keyPressEvent(event) { grabber.keyPressEvent(event); } function keyReleaseEvent(event) { grabber.keyReleaseEvent(event); } function cleanup() { grabber.cleanup(); } Controller.mousePressEvent.connect(pressEvent); Controller.mouseMoveEvent.connect(moveEvent); Controller.mouseReleaseEvent.connect(releaseEvent); Controller.keyPressEvent.connect(keyPressEvent); Controller.keyReleaseEvent.connect(keyReleaseEvent); Script.scriptEnding.connect(cleanup); }()); // END LOCAL_SCOPE