"use strict"; // handControllerGrab.js // // Created by Eric Levin on 9/2/15 // Additions by James B. Pollack @imgntn on 9/24/2015 // Additions By Seth Alves on 10/20/2015 // Copyright 2015 High Fidelity, Inc. // // Grabs physically moveable entities with hydra-like controllers; it works for either near or far objects. // // Distributed under the Apache License, Version 2.0. // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html /* global setEntityCustomData, getEntityCustomData, flatten, Xform, Script, Quat, Vec3, MyAvatar, Entities, Overlays, Settings, Reticle, Controller, Camera, Messages, Mat4, getControllerWorldLocation, getGrabPointSphereOffset */ (function() { // BEGIN LOCAL_SCOPE Script.include("../libraries/utils.js"); Script.include("../libraries/Xform.js"); Script.include("../libraries/controllers.js"); // // add lines where the hand ray picking is happening // var WANT_DEBUG = false; var WANT_DEBUG_STATE = false; var WANT_DEBUG_SEARCH_NAME = null; var FORCE_IGNORE_IK = false; var SHOW_GRAB_POINT_SPHERE = true; // // these tune time-averaging and "on" value for analog trigger // var TRIGGER_SMOOTH_RATIO = 0.1; // Time averaging of trigger - 0.0 disables smoothing var TRIGGER_OFF_VALUE = 0.1; var TRIGGER_ON_VALUE = TRIGGER_OFF_VALUE + 0.05; // Squeezed just enough to activate search or near grab var COLLIDE_WITH_AV_AFTER_RELEASE_DELAY = 0.25; // seconds var BUMPER_ON_VALUE = 0.5; var THUMB_ON_VALUE = 0.5; var HAPTIC_PULSE_STRENGTH = 1.0; var HAPTIC_PULSE_DURATION = 13.0; var HAPTIC_TEXTURE_STRENGTH = 0.1; var HAPTIC_TEXTURE_DURATION = 3.0; var HAPTIC_TEXTURE_DISTANCE = 0.002; var HAPTIC_DEQUIP_STRENGTH = 0.75; var HAPTIC_DEQUIP_DURATION = 50.0; var HAND_HEAD_MIX_RATIO = 0.0; // 0 = only use hands for search/move. 1 = only use head for search/move. var PICK_WITH_HAND_RAY = true; var EQUIP_SPHERE_SCALE_FACTOR = 0.65; // // distant manipulation // var DISTANCE_HOLDING_RADIUS_FACTOR = 3.5; // multiplied by distance between hand and object 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 var MOVE_WITH_HEAD = true; // experimental head-control of distantly held objects var COLORS_GRAB_SEARCHING_HALF_SQUEEZE = { red: 10, green: 10, blue: 255 }; var COLORS_GRAB_SEARCHING_FULL_SQUEEZE = { red: 250, green: 10, blue: 10 }; var COLORS_GRAB_DISTANCE_HOLD = { red: 238, green: 75, blue: 214 }; var PICK_MAX_DISTANCE = 500; // max length of pick-ray // // near grabbing // var EQUIP_RADIUS = 0.2; // radius used for palm vs equip-hotspot for equipping. // if EQUIP_HOTSPOT_RENDER_RADIUS is greater than zero, the hotspot will appear before the hand // has reached the required position, and then grow larger once the hand is close enough to equip. var EQUIP_HOTSPOT_RENDER_RADIUS = 0.0; // radius used for palm vs equip-hotspot for rendering hot-spots var MAX_EQUIP_HOTSPOT_RADIUS = 1.0; var NEAR_GRABBING_ACTION_TIMEFRAME = 0.05; // how quickly objects move to their new position var NEAR_GRAB_RADIUS = 0.04; // radius used for palm vs object for near grabbing. var NEAR_GRAB_MAX_DISTANCE = 1.0; // you cannot grab objects that are this far away from your hand var NEAR_GRAB_PICK_RADIUS = 0.25; // radius used for search ray vs object for near grabbing. var NEAR_GRABBING_KINEMATIC = true; // force objects to be kinematic when near-grabbed // if an equipped item is "adjusted" to be too far from the hand it's in, it will be unequipped. var CHECK_TOO_FAR_UNEQUIP_TIME = 0.3; // seconds, duration between checks var GRAB_POINT_SPHERE_RADIUS = NEAR_GRAB_RADIUS; var GRAB_POINT_SPHERE_COLOR = { red: 240, green: 240, blue: 240 }; var GRAB_POINT_SPHERE_ALPHA = 0.85; // // other constants // var RIGHT_HAND = 1; var LEFT_HAND = 0; var ZERO_VEC = { x: 0, y: 0, z: 0 }; var ONE_VEC = { x: 1, y: 1, z: 1 }; var NULL_UUID = "{00000000-0000-0000-0000-000000000000}"; // these control how long an abandoned pointer line or action will hang around var ACTION_TTL = 15; // seconds var ACTION_TTL_REFRESH = 5; var PICKS_PER_SECOND_PER_HAND = 60; var MSECS_PER_SEC = 1000.0; var GRABBABLE_PROPERTIES = [ "position", "registrationPoint", "rotation", "gravity", "collidesWith", "dynamic", "collisionless", "locked", "name", "shapeType", "parentID", "parentJointIndex", "density", "dimensions", "userData" ]; var GRABBABLE_DATA_KEY = "grabbableKey"; // shared with grab.js var GRAB_USER_DATA_KEY = "grabKey"; // shared with grab.js var DEFAULT_GRABBABLE_DATA = { disableReleaseVelocity: false }; // sometimes we want to exclude objects from being picked var USE_BLACKLIST = true; var blacklist = []; var FORBIDDEN_GRAB_NAMES = ["Grab Debug Entity", "grab pointer"]; var FORBIDDEN_GRAB_TYPES = ["Unknown", "Light", "PolyLine", "Zone"]; // states for the state machine var STATE_OFF = 0; var STATE_SEARCHING = 1; var STATE_DISTANCE_HOLDING = 2; var STATE_NEAR_GRABBING = 3; var STATE_NEAR_TRIGGER = 4; var STATE_FAR_TRIGGER = 5; var STATE_HOLD = 6; var STATE_ENTITY_TOUCHING = 7; var holdEnabled = true; var nearGrabEnabled = true; var farGrabEnabled = true; // "collidesWith" is specified by comma-separated list of group names // the possible group names are: static, dynamic, kinematic, myAvatar, otherAvatar var COLLIDES_WITH_WHILE_GRABBED = "dynamic,otherAvatar"; var HEART_BEAT_INTERVAL = 5 * MSECS_PER_SEC; var HEART_BEAT_TIMEOUT = 15 * MSECS_PER_SEC; var delayedDeactivateFunc; var delayedDeactivateTimeout; var delayedDeactivateEntityID; var CONTROLLER_STATE_MACHINE = {}; var mostRecentSearchingHand = RIGHT_HAND; var DEFAULT_SPHERE_MODEL_URL = "http://hifi-content.s3.amazonaws.com/alan/dev/equip-Fresnel-3.fbx"; CONTROLLER_STATE_MACHINE[STATE_OFF] = { name: "off", enterMethod: "offEnter", updateMethod: "off" }; CONTROLLER_STATE_MACHINE[STATE_SEARCHING] = { name: "searching", enterMethod: "searchEnter", updateMethod: "search" }; CONTROLLER_STATE_MACHINE[STATE_DISTANCE_HOLDING] = { name: "distance_holding", enterMethod: "distanceHoldingEnter", updateMethod: "distanceHolding" }; CONTROLLER_STATE_MACHINE[STATE_NEAR_GRABBING] = { name: "near_grabbing", enterMethod: "nearGrabbingEnter", updateMethod: "nearGrabbing" }; CONTROLLER_STATE_MACHINE[STATE_HOLD] = { name: "hold", enterMethod: "nearGrabbingEnter", updateMethod: "nearGrabbing" }; CONTROLLER_STATE_MACHINE[STATE_NEAR_TRIGGER] = { name: "trigger", enterMethod: "nearTriggerEnter", updateMethod: "nearTrigger" }; CONTROLLER_STATE_MACHINE[STATE_FAR_TRIGGER] = { name: "far_trigger", enterMethod: "farTriggerEnter", updateMethod: "farTrigger" }; CONTROLLER_STATE_MACHINE[STATE_ENTITY_TOUCHING] = { name: "entityTouching", enterMethod: "entityTouchingEnter", exitMethod: "entityTouchingExit", updateMethod: "entityTouching" }; function distanceBetweenPointAndEntityBoundingBox(point, entityProps) { var entityXform = new Xform(entityProps.rotation, entityProps.position); var localPoint = entityXform.inv().xformPoint(point); var minOffset = Vec3.multiplyVbyV(entityProps.registrationPoint, entityProps.dimensions); var maxOffset = Vec3.multiplyVbyV(Vec3.subtract(ONE_VEC, entityProps.registrationPoint), entityProps.dimensions); var localMin = Vec3.subtract(entityXform.trans, minOffset); var localMax = Vec3.sum(entityXform.trans, maxOffset); var v = {x: localPoint.x, y: localPoint.y, z: localPoint.z}; v.x = Math.max(v.x, localMin.x); v.x = Math.min(v.x, localMax.x); v.y = Math.max(v.y, localMin.y); v.y = Math.min(v.y, localMax.y); v.z = Math.max(v.z, localMin.z); v.z = Math.min(v.z, localMax.z); return Vec3.distance(v, localPoint); } function angleBetween(a, b) { return Math.acos(Vec3.dot(Vec3.normalize(a), Vec3.normalize(b))); } function projectOntoEntityXYPlane(entityID, worldPos) { var props = entityPropertiesCache.getProps(entityID); var invRot = Quat.inverse(props.rotation); var localPos = Vec3.multiplyQbyV(invRot, Vec3.subtract(worldPos, props.position)); var invDimensions = { x: 1 / props.dimensions.x, y: 1 / props.dimensions.y, z: 1 / props.dimensions.z }; var normalizedPos = Vec3.sum(Vec3.multiplyVbyV(localPos, invDimensions), props.registrationPoint); return { x: normalizedPos.x * props.dimensions.x, y: (1 - normalizedPos.y) * props.dimensions.y }; // flip y-axis } function handLaserIntersectEntity(entityID, start) { var worldHandPosition = start.position; var worldHandRotation = start.orientation; var props = entityPropertiesCache.getProps(entityID); if (props.position) { var planePosition = props.position; var planeNormal = Vec3.multiplyQbyV(props.rotation, {x: 0, y: 0, z: 1.0}); var rayStart = worldHandPosition; var rayDirection = Quat.getUp(worldHandRotation); var intersectionInfo = rayIntersectPlane(planePosition, planeNormal, rayStart, rayDirection); var intersectionPoint = planePosition; if (intersectionInfo.hit && intersectionInfo.distance > 0) { intersectionPoint = Vec3.sum(rayStart, Vec3.multiply(intersectionInfo.distance, rayDirection)); } else { intersectionPoint = planePosition; } intersectionInfo.point = intersectionPoint; intersectionInfo.normal = planeNormal; intersectionInfo.searchRay = { origin: rayStart, direction: rayDirection, length: PICK_MAX_DISTANCE }; return intersectionInfo; } else { // entity has been destroyed? or is no longer in cache return null; } } function rayIntersectPlane(planePosition, planeNormal, rayStart, rayDirection) { var rayDirectionDotPlaneNormal = Vec3.dot(rayDirection, planeNormal); if (rayDirectionDotPlaneNormal > 0.00001 || rayDirectionDotPlaneNormal < -0.00001) { var rayStartDotPlaneNormal = Vec3.dot(Vec3.subtract(planePosition, rayStart), planeNormal); var distance = rayStartDotPlaneNormal / rayDirectionDotPlaneNormal; return {hit: true, distance: distance}; } else { // ray is parallel to the plane return {hit: false, distance: 0}; } } function stateToName(state) { return CONTROLLER_STATE_MACHINE[state] ? CONTROLLER_STATE_MACHINE[state].name : "???"; } function getTag() { return "grab-" + MyAvatar.sessionUUID; } function colorPow(color, power) { return { red: Math.pow(color.red / 255.0, power) * 255, green: Math.pow(color.green / 255.0, power) * 255, blue: Math.pow(color.blue / 255.0, power) * 255 }; } function entityHasActions(entityID) { return Entities.getActionIDs(entityID).length > 0; } function findRayIntersection(pickRay, precise, include, exclude) { var entities = Entities.findRayIntersection(pickRay, precise, include, exclude, true); var overlays = Overlays.findRayIntersection(pickRay); if (!overlays.intersects || (entities.intersects && (entities.distance <= overlays.distance))) { return entities; } return overlays; } 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; } function propsArePhysical(props) { if (!props.dynamic) { return false; } var isPhysical = (props.shapeType && props.shapeType != 'none'); return isPhysical; } var USE_ATTACH_POINT_SETTINGS = true; var ATTACH_POINT_SETTINGS = "io.highfidelity.attachPoints"; function getAttachPointSettings() { try { var str = Settings.getValue(ATTACH_POINT_SETTINGS); if (str === "false") { return {}; } else { return JSON.parse(str); } } catch (err) { print("Error parsing attachPointSettings: " + err); return {}; } } function setAttachPointSettings(attachPointSettings) { var str = JSON.stringify(attachPointSettings); Settings.setValue(ATTACH_POINT_SETTINGS, str); } function getAttachPointForHotspotFromSettings(hotspot, hand) { var attachPointSettings = getAttachPointSettings(); var jointName = (hand === RIGHT_HAND) ? "RightHand" : "LeftHand"; var joints = attachPointSettings[hotspot.key]; if (joints) { return joints[jointName]; } else { return undefined; } } function storeAttachPointForHotspotInSettings(hotspot, hand, offsetPosition, offsetRotation) { var attachPointSettings = getAttachPointSettings(); var jointName = (hand === RIGHT_HAND) ? "RightHand" : "LeftHand"; var joints = attachPointSettings[hotspot.key]; if (!joints) { joints = {}; attachPointSettings[hotspot.key] = joints; } joints[jointName] = [offsetPosition, offsetRotation]; setAttachPointSettings(attachPointSettings); } function removeMyAvatarFromCollidesWith(origCollidesWith) { var collidesWithSplit = origCollidesWith.split(","); // remove myAvatar from the array for (var i = collidesWithSplit.length - 1; i >= 0; i--) { if (collidesWithSplit[i] === "myAvatar") { collidesWithSplit.splice(i, 1); } } return collidesWithSplit.join(); } // If another script is managing the reticle (as is done by HandControllerPointer), we should not be setting it here, // and we should not be showing lasers when someone else is using the Reticle to indicate a 2D minor mode. var EXTERNALLY_MANAGED_2D_MINOR_MODE = true; var EDIT_SETTING = "io.highfidelity.isEditting"; function isEditing() { var actualSettingValue = Settings.getValue(EDIT_SETTING) === "false" ? false : !!Settings.getValue(EDIT_SETTING); return EXTERNALLY_MANAGED_2D_MINOR_MODE && actualSettingValue; } function isIn2DMode() { // In this version, we make our own determination of whether we're aimed a HUD element, // because other scripts (such as handControllerPointer) might be using some other visualization // instead of setting Reticle.visible. return (EXTERNALLY_MANAGED_2D_MINOR_MODE && (Reticle.pointingAtSystemOverlay || Overlays.getOverlayAtPoint(Reticle.position))); } function restore2DMode() { if (!EXTERNALLY_MANAGED_2D_MINOR_MODE) { Reticle.setVisible(true); } } // EntityPropertiesCache is a helper class that contains a cache of entity properties. // the hope is to prevent excess calls to Entity.getEntityProperties() // // usage: // call EntityPropertiesCache.addEntities with all the entities that you are interested in. // This will fetch their properties. Then call EntityPropertiesCache.getProps to receive an object // containing a cache of all the properties previously fetched. function EntityPropertiesCache() { this.cache = {}; } EntityPropertiesCache.prototype.clear = function() { this.cache = {}; }; EntityPropertiesCache.prototype.addEntity = function(entityID) { var cacheEntry = this.cache[entityID]; if (cacheEntry && cacheEntry.refCount) { cacheEntry.refCount += 1; } else { this._updateCacheEntry(entityID); } }; EntityPropertiesCache.prototype.addEntities = function(entities) { var _this = this; entities.forEach(function(entityID) { _this.addEntity(entityID); }); }; EntityPropertiesCache.prototype._updateCacheEntry = function(entityID) { var props = Entities.getEntityProperties(entityID, GRABBABLE_PROPERTIES); // convert props.userData from a string to an object. var userData = {}; if (props.userData) { try { userData = JSON.parse(props.userData); } catch (err) { print("WARNING: malformed userData on " + entityID + ", name = " + props.name + ", error = " + err); } } props.userData = userData; props.refCount = 1; this.cache[entityID] = props; }; EntityPropertiesCache.prototype.update = function() { // delete any cacheEntries with zero refCounts. var entities = Object.keys(this.cache); for (var i = 0; i < entities.length; i++) { var props = this.cache[entities[i]]; if (props.refCount === 0) { delete this.cache[entities[i]]; } else { props.refCount = 0; } } }; EntityPropertiesCache.prototype.getProps = function(entityID) { var obj = this.cache[entityID]; return obj ? obj : undefined; }; EntityPropertiesCache.prototype.getGrabbableProps = function(entityID) { var props = this.cache[entityID]; if (props) { return props.userData.grabbableKey ? props.userData.grabbableKey : DEFAULT_GRABBABLE_DATA; } else { return undefined; } }; EntityPropertiesCache.prototype.getGrabProps = function(entityID) { var props = this.cache[entityID]; if (props) { return props.userData.grabKey ? props.userData.grabKey : {}; } else { return undefined; } }; EntityPropertiesCache.prototype.getWearableProps = function(entityID) { var props = this.cache[entityID]; if (props) { return props.userData.wearable ? props.userData.wearable : {}; } else { return undefined; } }; EntityPropertiesCache.prototype.getEquipHotspotsProps = function(entityID) { var props = this.cache[entityID]; if (props) { return props.userData.equipHotspots ? props.userData.equipHotspots : {}; } else { return undefined; } }; // global cache var entityPropertiesCache = new EntityPropertiesCache(); // Each overlayInfoSet describes a single equip hotspot. // It is an object with the following keys: // timestamp - last time this object was updated, used to delete stale hotspot overlays. // entityID - entity assosicated with this hotspot // localPosition - position relative to the entity // hotspot - hotspot object // overlays - array of overlay objects created by Overlay.addOverlay() // currentSize - current animated scale value // targetSize - the target of our scale animations // type - "sphere" or "model". function EquipHotspotBuddy() { // holds map from {string} hotspot.key to {object} overlayInfoSet. this.map = {}; // array of all hotspots that are highlighed. this.highlightedHotspots = []; } EquipHotspotBuddy.prototype.clear = function() { var keys = Object.keys(this.map); for (var i = 0; i < keys.length; i++) { var overlayInfoSet = this.map[keys[i]]; this.deleteOverlayInfoSet(overlayInfoSet); } this.map = {}; this.highlightedHotspots = []; }; EquipHotspotBuddy.prototype.highlightHotspot = function(hotspot) { this.highlightedHotspots.push(hotspot.key); }; EquipHotspotBuddy.prototype.updateHotspot = function(hotspot, timestamp) { var overlayInfoSet = this.map[hotspot.key]; if (!overlayInfoSet) { // create a new overlayInfoSet overlayInfoSet = { timestamp: timestamp, entityID: hotspot.entityID, localPosition: hotspot.localPosition, hotspot: hotspot, currentSize: 0, targetSize: 1, overlays: [] }; var diameter = hotspot.radius * 2; // override default sphere with a user specified model, if it exists. overlayInfoSet.overlays.push(Overlays.addOverlay("model", { url: hotspot.modelURL ? hotspot.modelURL : DEFAULT_SPHERE_MODEL_URL, position: hotspot.worldPosition, rotation: { x: 0, y: 0, z: 0, w: 1 }, dimensions: diameter * EQUIP_SPHERE_SCALE_FACTOR, scale: hotspot.modelScale, ignoreRayIntersection: true })); overlayInfoSet.type = "model"; this.map[hotspot.key] = overlayInfoSet; } else { overlayInfoSet.timestamp = timestamp; } }; EquipHotspotBuddy.prototype.updateHotspots = function(hotspots, timestamp) { var _this = this; hotspots.forEach(function(hotspot) { _this.updateHotspot(hotspot, timestamp); }); this.highlightedHotspots = []; }; EquipHotspotBuddy.prototype.update = function(deltaTime, timestamp) { var HIGHLIGHT_SIZE = 1.1; var NORMAL_SIZE = 1.0; var keys = Object.keys(this.map); for (var i = 0; i < keys.length; i++) { var overlayInfoSet = this.map[keys[i]]; // this overlayInfo is highlighted. if (this.highlightedHotspots.indexOf(keys[i]) != -1) { overlayInfoSet.targetSize = HIGHLIGHT_SIZE; } else { overlayInfoSet.targetSize = NORMAL_SIZE; } // start to fade out this hotspot. if (overlayInfoSet.timestamp != timestamp) { // because this item timestamp has expired, it might not be in the cache anymore.... entityPropertiesCache.addEntity(overlayInfoSet.entityID); overlayInfoSet.targetSize = 0; } // animate the size. var SIZE_TIMESCALE = 0.1; var tau = deltaTime / SIZE_TIMESCALE; if (tau > 1.0) { tau = 1.0; } overlayInfoSet.currentSize += (overlayInfoSet.targetSize - overlayInfoSet.currentSize) * tau; if (overlayInfoSet.timestamp != timestamp && overlayInfoSet.currentSize <= 0.05) { // this is an old overlay, that has finished fading out, delete it! overlayInfoSet.overlays.forEach(Overlays.deleteOverlay); delete this.map[keys[i]]; } else { // update overlay position, rotation to follow the object it's attached to. var props = entityPropertiesCache.getProps(overlayInfoSet.entityID); var entityXform = new Xform(props.rotation, props.position); var position = entityXform.xformPoint(overlayInfoSet.localPosition); var dimensions; if (overlayInfoSet.type == "sphere") { dimensions = overlayInfoSet.hotspot.radius * 2 * overlayInfoSet.currentSize * EQUIP_SPHERE_SCALE_FACTOR; } else { dimensions = overlayInfoSet.hotspot.radius * 2 * overlayInfoSet.currentSize; } overlayInfoSet.overlays.forEach(function(overlay) { Overlays.editOverlay(overlay, { position: position, rotation: props.rotation, dimensions: dimensions }); }); } } }; // global EquipHotspotBuddy instance var equipHotspotBuddy = new EquipHotspotBuddy(); function MyController(hand) { this.hand = hand; this.autoUnequipCounter = 0; this.grabPointIntersectsEntity = false; // handPosition is where the avatar's hand appears to be, in-world. this.getHandPosition = function () { if (this.hand === RIGHT_HAND) { return MyAvatar.getRightPalmPosition(); } else { return MyAvatar.getLeftPalmPosition(); } }; this.getHandRotation = function () { if (this.hand === RIGHT_HAND) { return MyAvatar.getRightPalmRotation(); } else { return MyAvatar.getLeftPalmRotation(); } }; this.handToController = function() { return (hand === RIGHT_HAND) ? Controller.Standard.RightHand : Controller.Standard.LeftHand; }; this.actionID = null; // action this script created... this.grabbedEntity = null; // on this entity. this.state = STATE_OFF; this.pointer = null; // entity-id of line object this.entityActivated = false; this.triggerValue = 0; // rolling average of trigger value this.triggerClicked = false; this.rawTriggerValue = 0; this.rawSecondaryValue = 0; this.rawThumbValue = 0; // for visualizations this.overlayLine = null; // for lights this.overlayLine = null; this.searchSphere = null; this.waitForTriggerRelease = false; // how far from camera to search intersection? var DEFAULT_SEARCH_SPHERE_DISTANCE = 1000; this.intersectionDistance = 0.0; this.searchSphereDistance = DEFAULT_SEARCH_SPHERE_DISTANCE; this.ignoreIK = false; this.offsetPosition = Vec3.ZERO; this.offsetRotation = Quat.IDENTITY; this.lastPickTime = 0; this.lastUnequipCheckTime = 0; this.equipOverlayInfoSetMap = {}; var _this = this; var suppressedIn2D = [STATE_OFF, STATE_SEARCHING]; this.ignoreInput = function() { // We've made the decision to use 'this' for new code, even though it is fragile, // in order to keep/ the code uniform without making any no-op line changes. return (-1 !== suppressedIn2D.indexOf(this.state)) && isIn2DMode(); }; this.update = function(deltaTime, timestamp) { this.updateSmoothedTrigger(); if (this.ignoreInput()) { this.turnOffVisualizations(); return; } if (CONTROLLER_STATE_MACHINE[this.state]) { var updateMethodName = CONTROLLER_STATE_MACHINE[this.state].updateMethod; var updateMethod = this[updateMethodName]; if (updateMethod) { updateMethod.call(this, deltaTime, timestamp); } else { print("WARNING: could not find updateMethod for state " + stateToName(this.state)); } } else { print("WARNING: could not find state " + this.state + " in state machine"); } }; this.callEntityMethodOnGrabbed = function(entityMethodName) { var args = [this.hand === RIGHT_HAND ? "right" : "left", MyAvatar.sessionUUID]; Entities.callEntityMethod(this.grabbedEntity, entityMethodName, args); }; this.setState = function(newState, reason) { setGrabCommunications((newState === STATE_DISTANCE_HOLDING) || (newState === STATE_NEAR_GRABBING)); if (WANT_DEBUG || WANT_DEBUG_STATE) { var oldStateName = stateToName(this.state); var newStateName = stateToName(newState); print("STATE (" + this.hand + "): " + newStateName + " <-- " + oldStateName + ", reason = " + reason); } // exit the old state if (CONTROLLER_STATE_MACHINE[this.state]) { var exitMethodName = CONTROLLER_STATE_MACHINE[this.state].exitMethod; var exitMethod = this[exitMethodName]; if (exitMethod) { exitMethod.call(this); } } else { print("WARNING: could not find state " + this.state + " in state machine"); } this.state = newState; // enter the new state if (CONTROLLER_STATE_MACHINE[newState]) { var enterMethodName = CONTROLLER_STATE_MACHINE[newState].enterMethod; var enterMethod = this[enterMethodName]; if (enterMethod) { enterMethod.call(this); } } else { print("WARNING: could not find newState " + newState + " in state machine"); } }; this.grabPointSphereOn = function() { if (!SHOW_GRAB_POINT_SPHERE) { return; } if (!MyAvatar.sessionUUID) { return; } if (!this.grabPointSphere) { this.grabPointSphere = Overlays.addOverlay("sphere", { localPosition: getGrabPointSphereOffset(this.handToController()), localRotation: { x: 0, y: 0, z: 0, w: 1 }, dimensions: GRAB_POINT_SPHERE_RADIUS, color: GRAB_POINT_SPHERE_COLOR, alpha: GRAB_POINT_SPHERE_ALPHA, solid: true, visible: true, ignoreRayIntersection: true, drawInFront: false, parentID: MyAvatar.sessionUUID, parentJointIndex: MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "_CONTROLLER_RIGHTHAND" : "_CONTROLLER_LEFTHAND") }); } }; this.grabPointSphereOff = function() { if (this.grabPointSphere) { Overlays.deleteOverlay(this.grabPointSphere); this.grabPointSphere = null; } }; this.searchSphereOn = function(location, size, color) { var rotation = Quat.lookAt(location, Camera.getPosition(), Vec3.UP); var brightColor = colorPow(color, 0.06); if (this.searchSphere === null) { var sphereProperties = { position: location, rotation: rotation, outerRadius: size * 1.2, innerColor: brightColor, outerColor: color, innerAlpha: 0.9, outerAlpha: 0.0, solid: true, ignoreRayIntersection: true, drawInFront: true, // Even when burried inside of something, show it. visible: true }; this.searchSphere = Overlays.addOverlay("circle3d", sphereProperties); } else { Overlays.editOverlay(this.searchSphere, { position: location, rotation: rotation, innerColor: brightColor, outerColor: color, innerAlpha: 1.0, outerAlpha: 0.0, outerRadius: size * 1.2, visible: true }); } }; this.overlayLineOn = function(closePoint, farPoint, color) { if (this.overlayLine === null) { var lineProperties = { glow: 1.0, start: closePoint, end: farPoint, color: color, ignoreRayIntersection: true, // always ignore this drawInFront: true, // Even when burried inside of something, show it. visible: true, alpha: 1 }; this.overlayLine = Overlays.addOverlay("line3d", lineProperties); } else { Overlays.editOverlay(this.overlayLine, { lineWidth: 5, start: closePoint, end: farPoint, color: color, visible: true, ignoreRayIntersection: true, // always ignore this drawInFront: true, // Even when burried inside of something, show it. alpha: 1 }); } }; this.searchIndicatorOn = function(distantPickRay) { var handPosition = distantPickRay.origin; var SEARCH_SPHERE_SIZE = 0.011; var SEARCH_SPHERE_FOLLOW_RATE = 0.50; if (this.intersectionDistance > 0) { // If we hit something with our pick ray, move the search sphere toward that distance this.searchSphereDistance = this.searchSphereDistance * SEARCH_SPHERE_FOLLOW_RATE + this.intersectionDistance * (1.0 - SEARCH_SPHERE_FOLLOW_RATE); } var searchSphereLocation = Vec3.sum(distantPickRay.origin, Vec3.multiply(distantPickRay.direction, this.searchSphereDistance)); this.searchSphereOn(searchSphereLocation, SEARCH_SPHERE_SIZE * this.searchSphereDistance, (this.triggerSmoothedGrab() || this.secondarySqueezed()) ? COLORS_GRAB_SEARCHING_FULL_SQUEEZE : COLORS_GRAB_SEARCHING_HALF_SQUEEZE); if (PICK_WITH_HAND_RAY) { this.overlayLineOn(handPosition, searchSphereLocation, (this.triggerSmoothedGrab() || this.secondarySqueezed()) ? COLORS_GRAB_SEARCHING_FULL_SQUEEZE : COLORS_GRAB_SEARCHING_HALF_SQUEEZE); } }; this.evalLightWorldTransform = function(modelPos, modelRot) { var MODEL_LIGHT_POSITION = { x: 0, y: -0.3, z: 0 }; var MODEL_LIGHT_ROTATION = Quat.angleAxis(-90, { x: 1, y: 0, z: 0 }); return { p: Vec3.sum(modelPos, Vec3.multiplyQbyV(modelRot, MODEL_LIGHT_POSITION)), q: Quat.multiply(modelRot, MODEL_LIGHT_ROTATION) }; }; this.lineOff = function() { if (this.pointer !== null) { Entities.deleteEntity(this.pointer); } this.pointer = null; }; this.overlayLineOff = function() { if (this.overlayLine !== null) { Overlays.deleteOverlay(this.overlayLine); } this.overlayLine = null; }; this.searchSphereOff = function() { if (this.searchSphere !== null) { Overlays.deleteOverlay(this.searchSphere); this.searchSphere = null; this.searchSphereDistance = DEFAULT_SEARCH_SPHERE_DISTANCE; this.intersectionDistance = 0.0; } }; this.turnOffVisualizations = function() { this.overlayLineOff(); this.grabPointSphereOff(); this.lineOff(); this.searchSphereOff(); restore2DMode(); }; this.triggerPress = function(value) { _this.rawTriggerValue = value; }; this.triggerClick = function(value) { _this.triggerClicked = value; }; this.secondaryPress = function(value) { _this.rawSecondaryValue = value; if (value > 0) { _this.release(); } }; this.updateSmoothedTrigger = function() { var triggerValue = this.rawTriggerValue; // smooth out trigger value this.triggerValue = (this.triggerValue * TRIGGER_SMOOTH_RATIO) + (triggerValue * (1.0 - TRIGGER_SMOOTH_RATIO)); }; this.triggerSmoothedGrab = function() { return this.triggerClicked; }; this.triggerSmoothedSqueezed = function() { return this.triggerValue > TRIGGER_ON_VALUE; }; this.triggerSmoothedReleased = function() { return this.triggerValue < TRIGGER_OFF_VALUE; }; this.secondarySqueezed = function() { return _this.rawSecondaryValue > BUMPER_ON_VALUE; }; this.secondaryReleased = function() { return _this.rawSecondaryValue < BUMPER_ON_VALUE; }; // this.triggerOrsecondarySqueezed = function () { // return triggerSmoothedSqueezed() || secondarySqueezed(); // } // this.triggerAndSecondaryReleased = function () { // return triggerSmoothedReleased() && secondaryReleased(); // } this.thumbPress = function(value) { _this.rawThumbValue = value; }; this.thumbPressed = function() { return _this.rawThumbValue > THUMB_ON_VALUE; }; this.thumbReleased = function() { return _this.rawThumbValue < THUMB_ON_VALUE; }; this.off = function(deltaTime, timestamp) { if (this.triggerSmoothedReleased()) { this.waitForTriggerRelease = false; } if (!this.waitForTriggerRelease && this.triggerSmoothedSqueezed()) { this.lastPickTime = 0; this.startingHandRotation = getControllerWorldLocation(this.handToController(), true).orientation; if (this.triggerSmoothedSqueezed()) { this.setState(STATE_SEARCHING, "trigger squeeze detected"); return; } } var controllerLocation = getControllerWorldLocation(this.handToController(), true); var worldHandPosition = controllerLocation.position; var candidateEntities = Entities.findEntities(worldHandPosition, MAX_EQUIP_HOTSPOT_RADIUS); entityPropertiesCache.addEntities(candidateEntities); var potentialEquipHotspot = this.chooseBestEquipHotspot(candidateEntities); if (!this.waitForTriggerRelease) { this.updateEquipHaptics(potentialEquipHotspot, worldHandPosition); } var nearEquipHotspots = this.chooseNearEquipHotspots(candidateEntities, EQUIP_HOTSPOT_RENDER_RADIUS); equipHotspotBuddy.updateHotspots(nearEquipHotspots, timestamp); if (potentialEquipHotspot) { equipHotspotBuddy.highlightHotspot(potentialEquipHotspot); } // when the grab-point enters a grabable entity, give a haptic pulse candidateEntities = Entities.findEntities(worldHandPosition, NEAR_GRAB_RADIUS); var grabbableEntities = candidateEntities.filter(function(entity) { return _this.entityIsNearGrabbable(entity, worldHandPosition, NEAR_GRAB_MAX_DISTANCE); }); if (grabbableEntities.length > 0) { if (!this.grabPointIntersectsEntity) { Controller.triggerHapticPulse(1, 20, this.hand); this.grabPointIntersectsEntity = true; this.grabPointSphereOn(); } } else { this.grabPointIntersectsEntity = false; this.grabPointSphereOff(); } }; this.clearEquipHaptics = function() { this.prevPotentialEquipHotspot = null; }; this.updateEquipHaptics = function(potentialEquipHotspot, currentLocation) { if (potentialEquipHotspot && !this.prevPotentialEquipHotspot || !potentialEquipHotspot && this.prevPotentialEquipHotspot) { Controller.triggerHapticPulse(HAPTIC_TEXTURE_STRENGTH, HAPTIC_TEXTURE_DURATION, this.hand); this.lastHapticPulseLocation = currentLocation; } else if (potentialEquipHotspot && Vec3.distance(this.lastHapticPulseLocation, currentLocation) > HAPTIC_TEXTURE_DISTANCE) { Controller.triggerHapticPulse(HAPTIC_TEXTURE_STRENGTH, HAPTIC_TEXTURE_DURATION, this.hand); this.lastHapticPulseLocation = currentLocation; } this.prevPotentialEquipHotspot = potentialEquipHotspot; }; this.heartBeatIsStale = function(data) { var now = Date.now(); return data.heartBeat === undefined || now - data.heartBeat > HEART_BEAT_TIMEOUT; }; // Performs ray pick test from the hand controller into the world // @param {number} which hand to use, RIGHT_HAND or LEFT_HAND // @returns {object} returns object with two keys entityID and distance // this.calcRayPickInfo = function(hand) { var controllerLocation = getControllerWorldLocation(this.handToController(), true); var worldHandPosition = controllerLocation.position; var worldHandRotation = controllerLocation.orientation; var pickRay = { origin: PICK_WITH_HAND_RAY ? worldHandPosition : Camera.position, direction: PICK_WITH_HAND_RAY ? Quat.getUp(worldHandRotation) : Vec3.mix(Quat.getUp(worldHandRotation), Quat.getFront(Camera.orientation), HAND_HEAD_MIX_RATIO), length: PICK_MAX_DISTANCE }; var result = { entityID: null, searchRay: pickRay, distance: PICK_MAX_DISTANCE }; var now = Date.now(); if (now - this.lastPickTime < MSECS_PER_SEC / PICKS_PER_SECOND_PER_HAND) { return result; } this.lastPickTime = now; var intersection; if (USE_BLACKLIST === true && blacklist.length !== 0) { intersection = findRayIntersection(pickRay, true, [], blacklist, true); } else { intersection = findRayIntersection(pickRay, true, [], [], true); } if (intersection.intersects) { return { entityID: intersection.entityID, overlayID: intersection.overlayID, searchRay: pickRay, distance: Vec3.distance(pickRay.origin, intersection.intersection), intersection: intersection.intersection, normal: intersection.surfaceNormal }; } else { return result; } }; this.entityWantsTrigger = function(entityID) { var grabbableProps = entityPropertiesCache.getGrabbableProps(entityID); return grabbableProps && grabbableProps.wantsTrigger; }; // returns a list of all equip-hotspots assosiated with this entity. // @param {UUID} entityID // @returns {Object[]} array of objects with the following fields. // * key {string} a string that can be used to uniquely identify this hotspot // * entityID {UUID} // * localPosition {Vec3} position of the hotspot in object space. // * worldPosition {vec3} position of the hotspot in world space. // * radius {number} radius of equip hotspot // * joints {Object} keys are joint names values are arrays of two elements: // offset position {Vec3} and offset rotation {Quat}, both are in the coordinate system of the joint. // * modelURL {string} url for model to use instead of default sphere. // * modelScale {Vec3} scale factor for model this.collectEquipHotspots = function(entityID) { var result = []; var props = entityPropertiesCache.getProps(entityID); var entityXform = new Xform(props.rotation, props.position); var equipHotspotsProps = entityPropertiesCache.getEquipHotspotsProps(entityID); if (equipHotspotsProps && equipHotspotsProps.length > 0) { var i, length = equipHotspotsProps.length; for (i = 0; i < length; i++) { var hotspot = equipHotspotsProps[i]; if (hotspot.position && hotspot.radius && hotspot.joints) { result.push({ key: entityID.toString() + i.toString(), entityID: entityID, localPosition: hotspot.position, worldPosition: entityXform.xformPoint(hotspot.position), radius: hotspot.radius, joints: hotspot.joints, modelURL: hotspot.modelURL, modelScale: hotspot.modelScale }); } } } else { var wearableProps = entityPropertiesCache.getWearableProps(entityID); if (wearableProps && wearableProps.joints) { result.push({ key: entityID.toString() + "0", entityID: entityID, localPosition: { x: 0, y: 0, z: 0 }, worldPosition: entityXform.pos, radius: EQUIP_RADIUS, joints: wearableProps.joints, modelURL: null, modelScale: null }); } } return result; }; this.hotspotIsEquippable = function(hotspot) { var props = entityPropertiesCache.getProps(hotspot.entityID); var grabProps = entityPropertiesCache.getGrabProps(hotspot.entityID); var debug = (WANT_DEBUG_SEARCH_NAME && props.name === WANT_DEBUG_SEARCH_NAME); var refCount = ("refCount" in grabProps) ? grabProps.refCount : 0; var okToEquipFromOtherHand = ((this.getOtherHandController().state == STATE_NEAR_GRABBING || this.getOtherHandController().state == STATE_DISTANCE_HOLDING) && this.getOtherHandController().grabbedEntity == hotspot.entityID); if (refCount > 0 && !this.heartBeatIsStale(grabProps) && !okToEquipFromOtherHand) { if (debug) { print("equip is skipping '" + props.name + "': grabbed by someone else"); } return false; } return true; }; this.entityIsGrabbable = function(entityID) { var grabbableProps = entityPropertiesCache.getGrabbableProps(entityID); var grabProps = entityPropertiesCache.getGrabProps(entityID); var props = entityPropertiesCache.getProps(entityID); var physical = propsArePhysical(props); var grabbable = false; var debug = (WANT_DEBUG_SEARCH_NAME && props.name === WANT_DEBUG_SEARCH_NAME); var refCount = ("refCount" in grabProps) ? grabProps.refCount : 0; if (physical) { // physical things default to grabbable grabbable = true; } else { // non-physical things default to non-grabbable unless they are already grabbed if (refCount > 0) { grabbable = true; } else { grabbable = false; } } if (grabbableProps.hasOwnProperty("grabbable") && refCount === 0) { grabbable = grabbableProps.grabbable; } if (!grabbable && !grabbableProps.wantsTrigger) { if (debug) { print("grab is skipping '" + props.name + "': not grabbable."); } return false; } if (FORBIDDEN_GRAB_TYPES.indexOf(props.type) >= 0) { if (debug) { print("grab is skipping '" + props.name + "': forbidden entity type."); } return false; } if (props.locked && !grabbableProps.wantsTrigger) { if (debug) { print("grab is skipping '" + props.name + "': locked and not triggerable."); } return false; } if (FORBIDDEN_GRAB_NAMES.indexOf(props.name) >= 0) { if (debug) { print("grab is skipping '" + props.name + "': forbidden name."); } return false; } return true; }; this.entityIsDistanceGrabbable = function(entityID, handPosition) { if (!this.entityIsGrabbable(entityID)) { return false; } var props = entityPropertiesCache.getProps(entityID); var distance = Vec3.distance(props.position, handPosition); var debug = (WANT_DEBUG_SEARCH_NAME && props.name === WANT_DEBUG_SEARCH_NAME); // we can't distance-grab non-physical var isPhysical = propsArePhysical(props); if (!isPhysical) { if (debug) { print("distance grab is skipping '" + props.name + "': not physical"); } return false; } if (distance > PICK_MAX_DISTANCE) { // too far away, don't grab if (debug) { print("distance grab is skipping '" + props.name + "': too far away."); } return false; } if (entityIsGrabbedByOther(entityID)) { // don't distance grab something that is already grabbed. if (debug) { print("distance grab is skipping '" + props.name + "': already grabbed by another."); } return false; } return true; }; this.entityIsNearGrabbable = function(entityID, handPosition, maxDistance) { if (!this.entityIsGrabbable(entityID)) { return false; } var props = entityPropertiesCache.getProps(entityID); var distance = Vec3.distance(props.position, handPosition); var debug = (WANT_DEBUG_SEARCH_NAME && props.name === WANT_DEBUG_SEARCH_NAME); if (distance > maxDistance) { // too far away, don't grab if (debug) { print(" grab is skipping '" + props.name + "': too far away."); } return false; } return true; }; this.chooseNearEquipHotspots = function(candidateEntities, distance) { var equippableHotspots = flatten(candidateEntities.map(function(entityID) { return _this.collectEquipHotspots(entityID); })).filter(function(hotspot) { return (_this.hotspotIsEquippable(hotspot) && Vec3.distance(hotspot.worldPosition, getControllerWorldLocation(_this.handToController(), true).position) < hotspot.radius + distance); }); return equippableHotspots; }; this.chooseBestEquipHotspot = function(candidateEntities) { var DISTANCE = 0; var equippableHotspots = this.chooseNearEquipHotspots(candidateEntities, DISTANCE); var _this = this; if (equippableHotspots.length > 0) { // sort by distance equippableHotspots.sort(function(a, b) { var handControllerLocation = getControllerWorldLocation(_this.handToController(), true); var aDistance = Vec3.distance(a.worldPosition, handControllerLocation.position); var bDistance = Vec3.distance(b.worldPosition, handControllerLocation.position); return aDistance - bDistance; }); return equippableHotspots[0]; } else { return null; } }; this.searchEnter = function() { mostRecentSearchingHand = this.hand; var rayPickInfo = this.calcRayPickInfo(this.hand); if (rayPickInfo.entityID || rayPickInfo.overlayID) { this.intersectionDistance = rayPickInfo.distance; this.searchSphereDistance = this.intersectionDistance; } }; this.search = function(deltaTime, timestamp) { var _this = this; var name; this.grabbedEntity = null; this.isInitialGrab = false; this.shouldResetParentOnRelease = false; this.checkForStrayChildren(); if (this.triggerSmoothedReleased()) { this.setState(STATE_OFF, "trigger released"); return; } var controllerLocation = getControllerWorldLocation(this.handToController(), true); var handPosition = controllerLocation.position; var rayPickInfo = this.calcRayPickInfo(this.hand); if (rayPickInfo.entityID) { entityPropertiesCache.addEntity(rayPickInfo.entityID); } var candidateHotSpotEntities = Entities.findEntities(handPosition, MAX_EQUIP_HOTSPOT_RADIUS); entityPropertiesCache.addEntities(candidateHotSpotEntities); var potentialEquipHotspot = this.chooseBestEquipHotspot(candidateHotSpotEntities); if (potentialEquipHotspot) { if (this.triggerSmoothedGrab() && holdEnabled) { this.grabbedHotspot = potentialEquipHotspot; this.grabbedEntity = potentialEquipHotspot.entityID; this.setState(STATE_HOLD, "equipping '" + entityPropertiesCache.getProps(this.grabbedEntity).name + "'"); return; } } var candidateEntities = Entities.findEntities(handPosition, NEAR_GRAB_RADIUS); var grabbableEntities = candidateEntities.filter(function(entity) { return _this.entityIsNearGrabbable(entity, handPosition, NEAR_GRAB_MAX_DISTANCE); }); if (rayPickInfo.entityID) { this.intersectionDistance = rayPickInfo.distance; if (this.entityIsGrabbable(rayPickInfo.entityID) && rayPickInfo.distance < NEAR_GRAB_PICK_RADIUS) { grabbableEntities.push(rayPickInfo.entityID); } } else if (rayPickInfo.overlayID) { this.intersectionDistance = rayPickInfo.distance; } else { this.intersectionDistance = 0; } var entity; if (grabbableEntities.length > 0) { // sort by distance grabbableEntities.sort(function(a, b) { var aDistance = Vec3.distance(entityPropertiesCache.getProps(a).position, handPosition); var bDistance = Vec3.distance(entityPropertiesCache.getProps(b).position, handPosition); return aDistance - bDistance; }); entity = grabbableEntities[0]; name = entityPropertiesCache.getProps(entity).name; this.grabbedEntity = entity; if (this.entityWantsTrigger(entity)) { if (this.triggerSmoothedGrab()) { this.setState(STATE_NEAR_TRIGGER, "near trigger '" + name + "'"); return; } else { // potentialNearTriggerEntity = entity; } } else { if (this.triggerSmoothedGrab() && nearGrabEnabled) { var props = entityPropertiesCache.getProps(entity); var grabProps = entityPropertiesCache.getGrabProps(entity); var refCount = grabProps.refCount ? grabProps.refCount : 0; if (refCount >= 1) { // if another person is holding the object, remember to restore the // parent info, when we are finished grabbing it. this.shouldResetParentOnRelease = true; this.previousParentID = props.parentID; this.previousParentJointIndex = props.parentJointIndex; } this.setState(STATE_NEAR_GRABBING, "near grab '" + name + "'"); return; } else { // potentialNearGrabEntity = entity; } } } var pointerEvent; if (rayPickInfo.entityID && Entities.wantsHandControllerPointerEvents(rayPickInfo.entityID)) { entity = rayPickInfo.entityID; name = entityPropertiesCache.getProps(entity).name; if (Entities.keyboardFocusEntity != entity) { Entities.keyboardFocusEntity = entity; pointerEvent = { type: "Move", id: this.hand + 1, // 0 is reserved for hardware mouse pos2D: projectOntoEntityXYPlane(entity, rayPickInfo.intersection), pos3D: rayPickInfo.intersection, normal: rayPickInfo.normal, direction: rayPickInfo.searchRay.direction, button: "None" }; this.hoverEntity = entity; Entities.sendHoverEnterEntity(entity, pointerEvent); } // send mouse events for button highlights and tooltips. if (this.hand == mostRecentSearchingHand || (this.hand !== mostRecentSearchingHand && this.getOtherHandController().state !== STATE_SEARCHING && this.getOtherHandController().state !== STATE_ENTITY_TOUCHING)) { // most recently searching hand has priority over other hand, for the purposes of button highlighting. pointerEvent = { type: "Move", id: this.hand + 1, // 0 is reserved for hardware mouse pos2D: projectOntoEntityXYPlane(entity, rayPickInfo.intersection), pos3D: rayPickInfo.intersection, normal: rayPickInfo.normal, direction: rayPickInfo.searchRay.direction, button: "None" }; Entities.sendMouseMoveOnEntity(entity, pointerEvent); Entities.sendHoverOverEntity(entity, pointerEvent); } if (this.triggerSmoothedGrab() && !isEditing()) { this.grabbedEntity = entity; this.setState(STATE_ENTITY_TOUCHING, "begin touching entity '" + name + "'"); return; } } else if (this.hoverEntity) { pointerEvent = { type: "Move", id: this.hand + 1 }; Entities.sendHoverLeaveEntity(this.hoverEntity, pointerEvent); this.hoverEntity = null; } if (rayPickInfo.entityID) { entity = rayPickInfo.entityID; name = entityPropertiesCache.getProps(entity).name; if (this.entityWantsTrigger(entity)) { if (this.triggerSmoothedGrab()) { this.grabbedEntity = entity; this.setState(STATE_FAR_TRIGGER, "far trigger '" + name + "'"); return; } else { // potentialFarTriggerEntity = entity; } } else if (this.entityIsDistanceGrabbable(rayPickInfo.entityID, handPosition)) { if (this.triggerSmoothedGrab() && !isEditing() && farGrabEnabled) { this.grabbedEntity = entity; this.setState(STATE_DISTANCE_HOLDING, "distance hold '" + name + "'"); return; } else { // potentialFarGrabEntity = entity; } } } this.updateEquipHaptics(potentialEquipHotspot, handPosition); var nearEquipHotspots = this.chooseNearEquipHotspots(candidateEntities, EQUIP_HOTSPOT_RENDER_RADIUS); equipHotspotBuddy.updateHotspots(nearEquipHotspots, timestamp); if (potentialEquipHotspot) { equipHotspotBuddy.highlightHotspot(potentialEquipHotspot); } if (farGrabEnabled) { this.searchIndicatorOn(rayPickInfo.searchRay); } Reticle.setVisible(false); }; this.distanceGrabTimescale = function(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; }; this.getMass = function(dimensions, density) { return (dimensions.x * dimensions.y * dimensions.z) * density; }; this.distanceHoldingEnter = function() { this.clearEquipHaptics(); this.grabPointSphereOff(); var worldControllerPosition = getControllerWorldLocation(this.handToController(), true).position; // transform the position into room space var worldToSensorMat = Mat4.inverse(MyAvatar.getSensorToWorldMatrix()); var roomControllerPosition = Mat4.transformPoint(worldToSensorMat, worldControllerPosition); var grabbedProperties = Entities.getEntityProperties(this.grabbedEntity, GRABBABLE_PROPERTIES); var now = Date.now(); // add the action and initialize some variables this.currentObjectPosition = grabbedProperties.position; this.currentObjectRotation = grabbedProperties.rotation; this.currentObjectTime = now; this.currentCameraOrientation = Camera.orientation; this.grabRadius = Vec3.distance(this.currentObjectPosition, worldControllerPosition); this.grabRadialVelocity = 0.0; // compute a constant based on the initial conditions which we use below to exagerate hand motion // onto the held object this.radiusScalar = Math.log(this.grabRadius + 1.0); if (this.radiusScalar < 1.0) { this.radiusScalar = 1.0; } // compute the mass for the purpose of energy and how quickly to move object this.mass = this.getMass(grabbedProperties.dimensions, grabbedProperties.density); var distanceToObject = Vec3.length(Vec3.subtract(MyAvatar.position, grabbedProperties.position)); var timeScale = this.distanceGrabTimescale(this.mass, distanceToObject); this.actionID = NULL_UUID; this.actionID = Entities.addAction("spring", this.grabbedEntity, { targetPosition: this.currentObjectPosition, linearTimeScale: timeScale, targetRotation: this.currentObjectRotation, angularTimeScale: timeScale, tag: getTag(), ttl: ACTION_TTL }); if (this.actionID === NULL_UUID) { this.actionID = null; } this.actionTimeout = now + (ACTION_TTL * MSECS_PER_SEC); if (this.actionID !== null) { this.activateEntity(this.grabbedEntity, grabbedProperties, false, true); this.callEntityMethodOnGrabbed("startDistanceGrab"); } Controller.triggerHapticPulse(HAPTIC_PULSE_STRENGTH, HAPTIC_PULSE_DURATION, this.hand); this.turnOffVisualizations(); this.previousRoomControllerPosition = roomControllerPosition; }; this.distanceHolding = function(deltaTime, timestamp) { if (!this.triggerClicked) { this.callEntityMethodOnGrabbed("releaseGrab"); // if we distance hold something and keep it very still before releasing it, it ends up // non-dynamic in bullet. If it's too still, give it a little bounce so it will fall. var velocity = Entities.getEntityProperties(this.grabbedEntity, ["velocity"]).velocity; if (Vec3.length(velocity) < 0.05) { // see EntityMotionState.cpp DYNAMIC_LINEAR_VELOCITY_THRESHOLD velocity = { x: 0.0, y: 0.2, z:0.0 }; Entities.editEntity(this.grabbedEntity, { velocity: velocity }); } this.setState(STATE_OFF, "trigger released"); return; } this.heartBeat(this.grabbedEntity); var controllerLocation = getControllerWorldLocation(this.handToController(), true); var worldControllerPosition = controllerLocation.position; var worldControllerRotation = controllerLocation.orientation; // also transform the position into room space var worldToSensorMat = Mat4.inverse(MyAvatar.getSensorToWorldMatrix()); var roomControllerPosition = Mat4.transformPoint(worldToSensorMat, worldControllerPosition); var grabbedProperties = Entities.getEntityProperties(this.grabbedEntity, GRABBABLE_PROPERTIES); var now = Date.now(); var deltaObjectTime = (now - this.currentObjectTime) / MSECS_PER_SEC; // convert to seconds this.currentObjectTime = now; // the action was set up when this.distanceHolding was called. update the targets. var radius = Vec3.distance(this.currentObjectPosition, worldControllerPosition) * this.radiusScalar * DISTANCE_HOLDING_RADIUS_FACTOR; if (radius < 1.0) { radius = 1.0; } var roomHandDelta = Vec3.subtract(roomControllerPosition, this.previousRoomControllerPosition); var worldHandDelta = Mat4.transformVector(MyAvatar.getSensorToWorldMatrix(), roomHandDelta); var handMoved = Vec3.multiply(worldHandDelta, radius); this.currentObjectPosition = Vec3.sum(this.currentObjectPosition, handMoved); this.callEntityMethodOnGrabbed("continueDistantGrab"); var defaultMoveWithHeadData = { disableMoveWithHead: false }; var handControllerData = getEntityCustomData('handControllerKey', this.grabbedEntity, defaultMoveWithHeadData); // Update radialVelocity var lastVelocity = Vec3.multiply(worldHandDelta, 1.0 / deltaObjectTime); var delta = Vec3.normalize(Vec3.subtract(grabbedProperties.position, worldControllerPosition)); var newRadialVelocity = Vec3.dot(lastVelocity, delta); var VELOCITY_AVERAGING_TIME = 0.016; var blendFactor = deltaObjectTime / VELOCITY_AVERAGING_TIME; if (blendFactor < 0.0) { blendFactor = 0.0; } else if (blendFactor > 1.0) { blendFactor = 1.0; } this.grabRadialVelocity = blendFactor * newRadialVelocity + (1.0 - blendFactor) * this.grabRadialVelocity; var RADIAL_GRAB_AMPLIFIER = 10.0; if (Math.abs(this.grabRadialVelocity) > 0.0) { this.grabRadius = this.grabRadius + (this.grabRadialVelocity * deltaObjectTime * this.grabRadius * RADIAL_GRAB_AMPLIFIER); } // don't let grabRadius go all the way to zero, because it can't come back from that var MINIMUM_GRAB_RADIUS = 0.1; if (this.grabRadius < MINIMUM_GRAB_RADIUS) { this.grabRadius = MINIMUM_GRAB_RADIUS; } var newTargetPosition = Vec3.multiply(this.grabRadius, Quat.getUp(worldControllerRotation)); newTargetPosition = Vec3.sum(newTargetPosition, worldControllerPosition); var objectToAvatar = Vec3.subtract(this.currentObjectPosition, MyAvatar.position); if (handControllerData.disableMoveWithHead !== true) { // mix in head motion if (MOVE_WITH_HEAD) { var objDistance = Vec3.length(objectToAvatar); var before = Vec3.multiplyQbyV(this.currentCameraOrientation, { x: 0.0, y: 0.0, z: objDistance }); var after = Vec3.multiplyQbyV(Camera.orientation, { x: 0.0, y: 0.0, z: objDistance }); var change = Vec3.multiply(Vec3.subtract(before, after), HAND_HEAD_MIX_RATIO); this.currentCameraOrientation = Camera.orientation; this.currentObjectPosition = Vec3.sum(this.currentObjectPosition, change); } } // visualizations var rayPickInfo = this.calcRayPickInfo(this.hand); this.overlayLineOn(rayPickInfo.searchRay.origin, grabbedProperties.position, COLORS_GRAB_DISTANCE_HOLD); var distanceToObject = Vec3.length(Vec3.subtract(MyAvatar.position, this.currentObjectPosition)); var success = Entities.updateAction(this.grabbedEntity, this.actionID, { targetPosition: newTargetPosition, linearTimeScale: this.distanceGrabTimescale(this.mass, distanceToObject), targetRotation: this.currentObjectRotation, angularTimeScale: this.distanceGrabTimescale(this.mass, distanceToObject), ttl: ACTION_TTL }); if (success) { this.actionTimeout = now + (ACTION_TTL * MSECS_PER_SEC); } else { print("continueDistanceHolding -- updateAction failed"); } this.previousRoomControllerPosition = roomControllerPosition; }; this.setupHoldAction = function() { this.actionID = Entities.addAction("hold", this.grabbedEntity, { hand: this.hand === RIGHT_HAND ? "right" : "left", timeScale: NEAR_GRABBING_ACTION_TIMEFRAME, relativePosition: this.offsetPosition, relativeRotation: this.offsetRotation, ttl: ACTION_TTL, kinematic: NEAR_GRABBING_KINEMATIC, kinematicSetVelocity: true, ignoreIK: this.ignoreIK }); if (this.actionID === NULL_UUID) { this.actionID = null; return false; } var now = Date.now(); this.actionTimeout = now + (ACTION_TTL * MSECS_PER_SEC); return true; }; this.projectVectorAlongAxis = function(position, axisStart, axisEnd) { var aPrime = Vec3.subtract(position, axisStart); var bPrime = Vec3.subtract(axisEnd, axisStart); var bPrimeMagnitude = Vec3.length(bPrime); var dotProduct = Vec3.dot(aPrime, bPrime); var scalar = dotProduct / bPrimeMagnitude; if (scalar < 0) { scalar = 0; } if (scalar > 1) { scalar = 1; } var projection = Vec3.sum(axisStart, Vec3.multiply(scalar, Vec3.normalize(bPrime))); return projection; }; this.dropGestureReset = function() { this.prevHandIsUpsideDown = false; }; this.dropGestureProcess = function(deltaTime) { var worldHandRotation = getControllerWorldLocation(this.handToController(), true).orientation; var localHandUpAxis = this.hand === RIGHT_HAND ? { x: 1, y: 0, z: 0 } : { x: -1, y: 0, z: 0 }; var worldHandUpAxis = Vec3.multiplyQbyV(worldHandRotation, localHandUpAxis); var DOWN = { x: 0, y: -1, z: 0 }; var DROP_ANGLE = Math.PI / 3; var HYSTERESIS_FACTOR = 1.1; var ROTATION_ENTER_THRESHOLD = Math.cos(DROP_ANGLE); var ROTATION_EXIT_THRESHOLD = Math.cos(DROP_ANGLE * HYSTERESIS_FACTOR); var rotationThreshold = this.prevHandIsUpsideDown ? ROTATION_EXIT_THRESHOLD : ROTATION_ENTER_THRESHOLD; var handIsUpsideDown = false; if (Vec3.dot(worldHandUpAxis, DOWN) > rotationThreshold) { handIsUpsideDown = true; } if (handIsUpsideDown != this.prevHandIsUpsideDown) { this.prevHandIsUpsideDown = handIsUpsideDown; Controller.triggerHapticPulse(HAPTIC_DEQUIP_STRENGTH, HAPTIC_DEQUIP_DURATION, this.hand); } return handIsUpsideDown; }; this.nearGrabbingEnter = function() { this.grabPointSphereOff(); this.lineOff(); this.overlayLineOff(); this.searchSphereOff(); this.dropGestureReset(); this.clearEquipHaptics(); Controller.triggerHapticPulse(HAPTIC_PULSE_STRENGTH, HAPTIC_PULSE_DURATION, this.hand); if (this.entityActivated) { var saveGrabbedID = this.grabbedEntity; this.release(); this.grabbedEntity = saveGrabbedID; } var otherHandController = this.getOtherHandController(); if (otherHandController.grabbedEntity == this.grabbedEntity && (otherHandController.state == STATE_NEAR_GRABBING || otherHandController.state == STATE_DISTANCE_HOLDING)) { otherHandController.setState(STATE_OFF, "other hand grabbed this entity"); } var grabbedProperties = Entities.getEntityProperties(this.grabbedEntity, GRABBABLE_PROPERTIES); this.activateEntity(this.grabbedEntity, grabbedProperties, false, false); var grabbableData = getEntityCustomData(GRABBABLE_DATA_KEY, this.grabbedEntity, DEFAULT_GRABBABLE_DATA); if (FORCE_IGNORE_IK) { this.ignoreIK = true; } else { this.ignoreIK = (grabbableData.ignoreIK !== undefined) ? grabbableData.ignoreIK : true; } var handRotation; var handPosition; if (this.ignoreIK) { var controllerLocation = getControllerWorldLocation(this.handToController(), false); handRotation = controllerLocation.orientation; handPosition = controllerLocation.position; } else { handRotation = this.getHandRotation(); handPosition = this.getHandPosition(); } var hasPresetPosition = false; if (this.state == STATE_HOLD && this.grabbedHotspot) { // if an object is "equipped" and has a predefined offset, use it. var offsets = USE_ATTACH_POINT_SETTINGS && getAttachPointForHotspotFromSettings(this.grabbedHotspot, this.hand); if (offsets) { this.offsetPosition = offsets[0]; this.offsetRotation = offsets[1]; hasPresetPosition = true; } else { var handJointName = this.hand === RIGHT_HAND ? "RightHand" : "LeftHand"; if (this.grabbedHotspot.joints[handJointName]) { this.offsetPosition = this.grabbedHotspot.joints[handJointName][0]; this.offsetRotation = this.grabbedHotspot.joints[handJointName][1]; hasPresetPosition = true; } } } else { var objectRotation = grabbedProperties.rotation; this.offsetRotation = Quat.multiply(Quat.inverse(handRotation), objectRotation); var currentObjectPosition = grabbedProperties.position; var offset = Vec3.subtract(currentObjectPosition, handPosition); this.offsetPosition = Vec3.multiplyQbyV(Quat.inverse(Quat.multiply(handRotation, this.offsetRotation)), offset); if (this.temporaryPositionOffset) { this.offsetPosition = this.temporaryPositionOffset; // hasPresetPosition = true; } } var isPhysical = propsArePhysical(grabbedProperties) || entityHasActions(this.grabbedEntity); if (isPhysical && this.state == STATE_NEAR_GRABBING) { // grab entity via action if (!this.setupHoldAction()) { return; } Messages.sendMessage('Hifi-Object-Manipulation', JSON.stringify({ action: 'grab', grabbedEntity: this.grabbedEntity, joint: this.hand === RIGHT_HAND ? "RightHand" : "LeftHand" })); } else { // grab entity via parenting this.actionID = null; var handJointIndex; if (this.ignoreIK) { handJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "_CONTROLLER_RIGHTHAND" : "_CONTROLLER_LEFTHAND"); } else { handJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "RightHand" : "LeftHand"); } var reparentProps = { parentID: MyAvatar.sessionUUID, parentJointIndex: handJointIndex, velocity: {x: 0, y: 0, z: 0}, angularVelocity: {x: 0, y: 0, z: 0} }; if (hasPresetPosition) { reparentProps.localPosition = this.offsetPosition; reparentProps.localRotation = this.offsetRotation; } Entities.editEntity(this.grabbedEntity, reparentProps); Messages.sendMessage('Hifi-Object-Manipulation', JSON.stringify({ action: 'equip', grabbedEntity: this.grabbedEntity, joint: this.hand === RIGHT_HAND ? "RightHand" : "LeftHand" })); } Entities.editEntity(this.grabbedEntity, { velocity: { x: 0, y: 0, z: 0 }, angularVelocity: { x: 0, y: 0, z: 0 }, dynamic: false }); if (this.state == STATE_NEAR_GRABBING) { this.callEntityMethodOnGrabbed("startNearGrab"); } else { // this.state == STATE_HOLD this.callEntityMethodOnGrabbed("startEquip"); } this.currentHandControllerTipPosition = (this.hand === RIGHT_HAND) ? MyAvatar.rightHandTipPosition : MyAvatar.leftHandTipPosition; this.currentObjectTime = Date.now(); this.currentObjectPosition = grabbedProperties.position; this.currentObjectRotation = grabbedProperties.rotation; this.currentVelocity = ZERO_VEC; this.currentAngularVelocity = ZERO_VEC; this.prevDropDetected = false; }; this.nearGrabbing = function(deltaTime, timestamp) { this.grabPointSphereOff(); if (this.state == STATE_NEAR_GRABBING && !this.triggerClicked) { this.callEntityMethodOnGrabbed("releaseGrab"); this.setState(STATE_OFF, "trigger released"); return; } if (this.state == STATE_HOLD) { var dropDetected = this.dropGestureProcess(deltaTime); if (this.triggerSmoothedReleased()) { this.waitForTriggerRelease = false; } if (dropDetected && this.prevDropDetected != dropDetected) { this.waitForTriggerRelease = true; } // highlight the grabbed hotspot when the dropGesture is detected. if (dropDetected) { entityPropertiesCache.addEntity(this.grabbedHotspot.entityID); equipHotspotBuddy.updateHotspot(this.grabbedHotspot, timestamp); equipHotspotBuddy.highlightHotspot(this.grabbedHotspot); } if (dropDetected && !this.waitForTriggerRelease && this.triggerSmoothedGrab()) { this.callEntityMethodOnGrabbed("releaseEquip"); // store the offset attach points into preferences. if (USE_ATTACH_POINT_SETTINGS && this.grabbedHotspot && this.grabbedEntity) { var prefprops = Entities.getEntityProperties(this.grabbedEntity, ["localPosition", "localRotation"]); if (prefprops && prefprops.localPosition && prefprops.localRotation) { storeAttachPointForHotspotInSettings(this.grabbedHotspot, this.hand, prefprops.localPosition, prefprops.localRotation); } } var grabbedEntity = this.grabbedEntity; this.release(); this.grabbedEntity = grabbedEntity; this.setState(STATE_NEAR_GRABBING, "drop gesture detected"); return; } this.prevDropDetected = dropDetected; } this.heartBeat(this.grabbedEntity); var props = Entities.getEntityProperties(this.grabbedEntity, ["localPosition", "parentID", "position", "rotation", "dimensions", "registrationPoint"]); if (!props.position) { // server may have reset, taking our equipped entity with it. move back to "off" state this.callEntityMethodOnGrabbed("releaseGrab"); this.setState(STATE_OFF, "entity has no position property"); return; } var now = Date.now(); if (this.state == STATE_HOLD && now - this.lastUnequipCheckTime > MSECS_PER_SEC * CHECK_TOO_FAR_UNEQUIP_TIME) { this.lastUnequipCheckTime = now; if (props.parentID == MyAvatar.sessionUUID) { var handPosition; if (this.ignoreIK) { handPosition = getControllerWorldLocation(this.handToController(), false).position; } else { handPosition = this.getHandPosition(); } var TEAR_AWAY_DISTANCE = 0.1; var dist = distanceBetweenPointAndEntityBoundingBox(handPosition, props); if (dist > TEAR_AWAY_DISTANCE) { this.autoUnequipCounter += 1; } else { this.autoUnequipCounter = 0; } if (this.autoUnequipCounter > 1) { // for whatever reason, the held/equipped entity has been pulled away. ungrab or unequip. print("handControllerGrab -- autoreleasing held or equipped item because it is far from hand." + props.parentID + ", dist = " + dist); if (this.state == STATE_NEAR_GRABBING) { this.callEntityMethodOnGrabbed("releaseGrab"); } else { // this.state == STATE_HOLD this.callEntityMethodOnGrabbed("releaseEquip"); } this.setState(STATE_OFF, "held object too far away"); return; } } } // Keep track of the fingertip velocity to impart when we release the object. // Note that the idea of using a constant 'tip' velocity regardless of the // object's actual held offset is an idea intended to make it easier to throw things: // Because we might catch something or transfer it between hands without a good idea // of it's actual offset, let's try imparting a velocity which is at a fixed radius // from the palm. var handControllerPosition = (this.hand === RIGHT_HAND) ? MyAvatar.rightHandPosition : MyAvatar.leftHandPosition; var deltaObjectTime = (now - this.currentObjectTime) / MSECS_PER_SEC; // convert to seconds if (deltaObjectTime > 0.0) { var worldDeltaPosition = Vec3.subtract(props.position, this.currentObjectPosition); var previousEulers = Quat.safeEulerAngles(this.currentObjectRotation); var newEulers = Quat.safeEulerAngles(props.rotation); var worldDeltaRotation = Vec3.subtract(newEulers, previousEulers); this.currentVelocity = Vec3.multiply(worldDeltaPosition, 1.0 / deltaObjectTime); this.currentAngularVelocity = Vec3.multiply(worldDeltaRotation, Math.PI / (deltaObjectTime * 180.0)); this.currentObjectPosition = props.position; this.currentObjectRotation = props.rotation; } this.currentHandControllerTipPosition = handControllerPosition; this.currentObjectTime = now; if (this.state === STATE_HOLD) { this.callEntityMethodOnGrabbed("continueEquip"); } if (this.state == STATE_NEAR_GRABBING) { this.callEntityMethodOnGrabbed("continueNearGrab"); } if (this.actionID && this.actionTimeout - now < ACTION_TTL_REFRESH * MSECS_PER_SEC) { // if less than a 5 seconds left, refresh the actions ttl var success = Entities.updateAction(this.grabbedEntity, this.actionID, { hand: this.hand === RIGHT_HAND ? "right" : "left", timeScale: NEAR_GRABBING_ACTION_TIMEFRAME, relativePosition: this.offsetPosition, relativeRotation: this.offsetRotation, ttl: ACTION_TTL, kinematic: NEAR_GRABBING_KINEMATIC, kinematicSetVelocity: true, ignoreIK: this.ignoreIK }); if (success) { this.actionTimeout = now + (ACTION_TTL * MSECS_PER_SEC); } else { print("continueNearGrabbing -- updateAction failed"); Entities.deleteAction(this.grabbedEntity, this.actionID); this.setupHoldAction(); } } }; this.nearTriggerEnter = function() { this.clearEquipHaptics(); this.grabPointSphereOff(); Controller.triggerShortHapticPulse(1.0, this.hand); this.callEntityMethodOnGrabbed("startNearTrigger"); }; this.farTriggerEnter = function() { this.clearEquipHaptics(); this.grabPointSphereOff(); this.callEntityMethodOnGrabbed("startFarTrigger"); }; this.nearTrigger = function(deltaTime, timestamp) { if (this.triggerSmoothedReleased()) { this.callEntityMethodOnGrabbed("stopNearTrigger"); this.setState(STATE_OFF, "trigger released"); return; } this.callEntityMethodOnGrabbed("continueNearTrigger"); }; this.farTrigger = function(deltaTime, timestamp) { if (this.triggerSmoothedReleased()) { this.callEntityMethodOnGrabbed("stopFarTrigger"); this.setState(STATE_OFF, "trigger released"); return; } var pickRay = { origin: getControllerWorldLocation(this.handToController(), false).position, direction: Quat.getUp(getControllerWorldLocation(this.handToController(), false).orientation) }; var now = Date.now(); if (now - this.lastPickTime > MSECS_PER_SEC / PICKS_PER_SECOND_PER_HAND) { var intersection = findRayIntersection(pickRay, true, [], [], true); if (intersection.accurate || intersection.overlayID) { this.lastPickTime = now; if (intersection.entityID != this.grabbedEntity) { this.callEntityMethodOnGrabbed("stopFarTrigger"); this.setState(STATE_OFF, "laser moved off of entity"); return; } if (intersection.intersects) { this.intersectionDistance = Vec3.distance(pickRay.origin, intersection.intersection); } if (farGrabEnabled) { this.searchIndicatorOn(pickRay); } } } this.callEntityMethodOnGrabbed("continueFarTrigger"); }; this.offEnter = function() { this.release(); }; this.entityTouchingEnter = function() { // test for intersection between controller laser and web entity plane. var intersectInfo = handLaserIntersectEntity(this.grabbedEntity, getControllerWorldLocation(this.handToController(), true)); if (intersectInfo) { var pointerEvent = { type: "Press", id: this.hand + 1, // 0 is reserved for hardware mouse pos2D: projectOntoEntityXYPlane(this.grabbedEntity, intersectInfo.point), pos3D: intersectInfo.point, normal: intersectInfo.normal, direction: intersectInfo.searchRay.direction, button: "Primary", isPrimaryHeld: true }; Entities.sendMousePressOnEntity(this.grabbedEntity, pointerEvent); Entities.sendClickDownOnEntity(this.grabbedEntity, pointerEvent); this.touchingEnterTimer = 0; this.touchingEnterPointerEvent = pointerEvent; this.touchingEnterPointerEvent.button = "None"; this.deadspotExpired = false; } }; this.entityTouchingExit = function() { // test for intersection between controller laser and web entity plane. var intersectInfo = handLaserIntersectEntity(this.grabbedEntity, getControllerWorldLocation(this.handToController(), true)); if (intersectInfo) { var pointerEvent; if (this.deadspotExpired) { pointerEvent = { type: "Release", id: this.hand + 1, // 0 is reserved for hardware mouse pos2D: projectOntoEntityXYPlane(this.grabbedEntity, intersectInfo.point), pos3D: intersectInfo.point, normal: intersectInfo.normal, direction: intersectInfo.searchRay.direction, button: "Primary" }; } else { pointerEvent = this.touchingEnterPointerEvent; pointerEvent.type = "Release"; pointerEvent.button = "Primary"; pointerEvent.isPrimaryHeld = false; } Entities.sendMouseReleaseOnEntity(this.grabbedEntity, pointerEvent); Entities.sendClickReleaseOnEntity(this.grabbedEntity, pointerEvent); Entities.sendHoverLeaveEntity(this.grabbedEntity, pointerEvent); } this.focusedEntity = null; }; this.entityTouching = function(dt) { this.touchingEnterTimer += dt; entityPropertiesCache.addEntity(this.grabbedEntity); if (!this.triggerSmoothedGrab()) { this.setState(STATE_OFF, "released trigger"); return; } // test for intersection between controller laser and web entity plane. var intersectInfo = handLaserIntersectEntity(this.grabbedEntity, getControllerWorldLocation(this.handToController(), true)); if (intersectInfo) { if (Entities.keyboardFocusEntity != this.grabbedEntity) { Entities.keyboardFocusEntity = this.grabbedEntity; } var pointerEvent = { type: "Move", id: this.hand + 1, // 0 is reserved for hardware mouse pos2D: projectOntoEntityXYPlane(this.grabbedEntity, intersectInfo.point), pos3D: intersectInfo.point, normal: intersectInfo.normal, direction: intersectInfo.searchRay.direction, button: "NoButtons", isPrimaryHeld: true }; var POINTER_PRESS_TO_MOVE_DELAY = 0.15; // seconds var POINTER_PRESS_TO_MOVE_DEADSPOT_ANGLE = 0.05; // radians ~ 3 degrees if (this.deadspotExpired || this.touchingEnterTimer > POINTER_PRESS_TO_MOVE_DELAY || angleBetween(pointerEvent.direction, this.touchingEnterPointerEvent.direction) > POINTER_PRESS_TO_MOVE_DEADSPOT_ANGLE) { Entities.sendMouseMoveOnEntity(this.grabbedEntity, pointerEvent); Entities.sendHoldingClickOnEntity(this.grabbedEntity, pointerEvent); this.deadspotExpired = true; } this.intersectionDistance = intersectInfo.distance; if (farGrabEnabled) { this.searchIndicatorOn(intersectInfo.searchRay); } Reticle.setVisible(false); } else { this.setState(STATE_OFF, "grabbed entity was destroyed"); return; } }; this.release = function() { this.turnOffVisualizations(); var noVelocity = false; if (this.grabbedEntity !== null) { // Make a small release haptic pulse if we really were holding something Controller.triggerHapticPulse(HAPTIC_PULSE_STRENGTH, HAPTIC_PULSE_DURATION, this.hand); // If this looks like the release after adjusting something still held in the other hand, print the position // and rotation of the held thing to help content creators set the userData. var grabData = getEntityCustomData(GRAB_USER_DATA_KEY, this.grabbedEntity, {}); this.printNewOffsets = (grabData.refCount > 1); if (this.actionID !== null) { Entities.deleteAction(this.grabbedEntity, this.actionID); // sometimes we want things to stay right where they are when we let go. var releaseVelocityData = getEntityCustomData(GRABBABLE_DATA_KEY, this.grabbedEntity, DEFAULT_GRABBABLE_DATA); if (releaseVelocityData.disableReleaseVelocity === true || // this next line allowed both: // (1) far-grab, pull to self, near grab, then throw // (2) equip something physical and adjust it with a other-hand grab without the thing drifting grabData.refCount > 1) { noVelocity = true; } } this.deactivateEntity(this.grabbedEntity, noVelocity); Messages.sendMessage('Hifi-Object-Manipulation', JSON.stringify({ action: 'release', grabbedEntity: this.grabbedEntity, joint: this.hand === RIGHT_HAND ? "RightHand" : "LeftHand" })); } this.actionID = null; this.grabbedEntity = null; this.grabbedHotspot = null; if (this.triggerSmoothedGrab()) { this.waitForTriggerRelease = true; } }; this.cleanup = function() { this.release(); this.grabPointSphereOff(); }; this.heartBeat = function(entityID) { var now = Date.now(); if (now - this.lastHeartBeat > HEART_BEAT_INTERVAL) { var data = getEntityCustomData(GRAB_USER_DATA_KEY, entityID, {}); data.heartBeat = now; setEntityCustomData(GRAB_USER_DATA_KEY, entityID, data); this.lastHeartBeat = now; } }; this.resetAbandonedGrab = function(entityID) { print("cleaning up abandoned grab on " + entityID); var data = getEntityCustomData(GRAB_USER_DATA_KEY, entityID, {}); data.refCount = 1; setEntityCustomData(GRAB_USER_DATA_KEY, entityID, data); this.deactivateEntity(entityID, false); }; this.activateEntity = function(entityID, grabbedProperties, wasLoaded, collideWithStatic) { this.autoUnequipCounter = 0; if (this.entityActivated) { return; } this.entityActivated = true; if (delayedDeactivateTimeout && delayedDeactivateEntityID == entityID) { // we have a timeout waiting to set collisions with myAvatar back on (so that when something // is thrown it doesn't collide with the avatar's capsule the moment it's released). We've // regrabbed the entity before the timeout fired, so cancel the timeout, run the function now // and adjust the grabbedProperties. This will make the saved set of properties (the ones that // get re-instated after all the grabs have been released) be correct. Script.clearTimeout(delayedDeactivateTimeout); delayedDeactivateTimeout = null; grabbedProperties.collidesWith = delayedDeactivateFunc(); } var data = getEntityCustomData(GRAB_USER_DATA_KEY, entityID, {}); var now = Date.now(); if (wasLoaded) { data.refCount = 1; } else { data.refCount = data.refCount ? data.refCount + 1 : 1; // zero gravity and set ignoreForCollisions in a way that lets us put them back, after all grabs are done if (data.refCount == 1) { data.heartBeat = now; this.lastHeartBeat = now; this.isInitialGrab = true; data.gravity = grabbedProperties.gravity; data.collidesWith = grabbedProperties.collidesWith; data.collisionless = grabbedProperties.collisionless; data.dynamic = grabbedProperties.dynamic; data.parentID = wasLoaded ? NULL_UUID : grabbedProperties.parentID; data.parentJointIndex = grabbedProperties.parentJointIndex; var whileHeldProperties = { gravity: { x: 0, y: 0, z: 0 }, "collidesWith": collideWithStatic ? COLLIDES_WITH_WHILE_GRABBED + ",static" : COLLIDES_WITH_WHILE_GRABBED }; Entities.editEntity(entityID, whileHeldProperties); } else if (data.refCount > 1) { if (this.heartBeatIsStale(data)) { // this entity has userData suggesting it is grabbed, but nobody is updating the hearbeat. // deactivate it before grabbing. this.resetAbandonedGrab(entityID); grabbedProperties = Entities.getEntityProperties(this.grabbedEntity, GRABBABLE_PROPERTIES); return this.activateEntity(entityID, grabbedProperties, wasLoaded, false); } this.isInitialGrab = false; // if an object is being grabbed by more than one person (or the same person twice, but nevermind), switch // the collision groups so that it wont collide with "other" avatars. This avoids a situation where two // people are holding something and one of them will be able (if the other releases at the right time) to // bootstrap themselves with the held object. This happens because the meaning of "otherAvatar" in // the collision mask hinges on who the physics simulation owner is. Entities.editEntity(entityID, { // "collidesWith": removeAvatarsFromCollidesWith(grabbedProperties.collidesWith) collisionless: true }); } } setEntityCustomData(GRAB_USER_DATA_KEY, entityID, data); return data; }; this.checkForStrayChildren = function() { // sometimes things can get parented to a hand and this script is unaware. Search for such entities and // unhook them. var handJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "RightHand" : "LeftHand"); var children = Entities.getChildrenIDsOfJoint(MyAvatar.sessionUUID, handJointIndex); var controllerJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "_CONTROLLER_RIGHTHAND" : "_CONTROLLER_LEFTHAND"); children.concat(Entities.getChildrenIDsOfJoint(MyAvatar.sessionUUID, controllerJointIndex)); children.forEach(function(childID) { print("disconnecting stray child of hand: (" + _this.hand + ") " + childID); Entities.editEntity(childID, { parentID: NULL_UUID }); }); }; this.delayedDeactivateEntity = function(entityID, collidesWith) { // If, before the grab started, the held entity collided with myAvatar, we do the deactivation in // two parts. Most of it is done in deactivateEntity(), but the final collidesWith and refcount // are delayed a bit. This keeps thrown things from colliding with the avatar's capsule so often. // The refcount is handled in this delayed fashion so things don't get confused if someone else // grabs the entity before the timeout fires. Entities.editEntity(entityID, { collidesWith: collidesWith }); var data = getEntityCustomData(GRAB_USER_DATA_KEY, entityID, {}); if (data && data.refCount) { data.refCount = data.refCount - 1; if (data.refCount < 1) { data = null; } } else { data = null; } setEntityCustomData(GRAB_USER_DATA_KEY, entityID, data); }; this.deactivateEntity = function(entityID, noVelocity, delayed) { var deactiveProps; if (!this.entityActivated) { return; } this.entityActivated = false; var data = getEntityCustomData(GRAB_USER_DATA_KEY, entityID, {}); var doDelayedDeactivate = false; if (data && data.refCount) { data.refCount = data.refCount - 1; if (data.refCount < 1) { deactiveProps = { gravity: data.gravity, // don't set collidesWith myAvatar back right away, because thrown things tend to bounce off the // avatar's capsule. collidesWith: removeMyAvatarFromCollidesWith(data.collidesWith), collisionless: data.collisionless, dynamic: data.dynamic, parentID: data.parentID, parentJointIndex: data.parentJointIndex }; doDelayedDeactivate = (data.collidesWith.indexOf("myAvatar") >= 0); if (doDelayedDeactivate) { var delayedCollidesWith = data.collidesWith; var delayedEntityID = entityID; delayedDeactivateFunc = function() { // set collidesWith back to original value a bit later than the rest delayedDeactivateTimeout = null; _this.delayedDeactivateEntity(delayedEntityID, delayedCollidesWith); return delayedCollidesWith; }; delayedDeactivateTimeout = Script.setTimeout(delayedDeactivateFunc, COLLIDE_WITH_AV_AFTER_RELEASE_DELAY * MSECS_PER_SEC); delayedDeactivateEntityID = entityID; } // things that are held by parenting and dropped with no velocity will end up as "static" in bullet. If // it looks like the dropped thing should fall, give it a little velocity. var props = Entities.getEntityProperties(entityID, ["parentID", "velocity", "dynamic", "shapeType"]); var parentID = props.parentID; if (!noVelocity && parentID == MyAvatar.sessionUUID && Vec3.length(data.gravity) > 0.0 && data.dynamic && data.parentID == NULL_UUID && !data.collisionless) { deactiveProps.velocity = this.currentVelocity; } if (noVelocity) { deactiveProps.velocity = { x: 0.0, y: 0.0, z: 0.0 }; deactiveProps.angularVelocity = { x: 0.0, y: 0.0, z: 0.0 }; } Entities.editEntity(entityID, deactiveProps); data = null; } else if (this.shouldResetParentOnRelease) { // we parent-grabbed this from another parent grab. try to put it back where we found it. deactiveProps = { parentID: this.previousParentID, parentJointIndex: this.previousParentJointIndex, velocity: { x: 0.0, y: 0.0, z: 0.0 }, angularVelocity: { x: 0.0, y: 0.0, z: 0.0 } }; Entities.editEntity(entityID, deactiveProps); if (this.printNewOffsets) { var grabbedProperties = Entities.getEntityProperties(this.grabbedEntity, ["localPosition", "localRotation"]); if (grabbedProperties && grabbedProperties.localPosition && grabbedProperties.localRotation) { print((this.hand === RIGHT_HAND ? '"LeftHand"' : '"RightHand"') + ":" + '[{"x":' + grabbedProperties.localPosition.x + ', "y":' + grabbedProperties.localPosition.y + ', "z":' + grabbedProperties.localPosition.z + '}, {"x":' + grabbedProperties.localRotation.x + ', "y":' + grabbedProperties.localRotation.y + ', "z":' + grabbedProperties.localRotation.z + ', "w":' + grabbedProperties.localRotation.w + '}]'); } } } else if (noVelocity) { Entities.editEntity(entityID, { velocity: { x: 0.0, y: 0.0, z: 0.0 }, angularVelocity: { x: 0.0, y: 0.0, z: 0.0 }, dynamic: data.dynamic }); } } else { data = null; } if (!doDelayedDeactivate) { setEntityCustomData(GRAB_USER_DATA_KEY, entityID, data); } }; this.getOtherHandController = function() { return (this.hand === RIGHT_HAND) ? leftController : rightController; }; } var rightController = new MyController(RIGHT_HAND); var leftController = new MyController(LEFT_HAND); var MAPPING_NAME = "com.highfidelity.handControllerGrab"; var mapping = Controller.newMapping(MAPPING_NAME); mapping.from([Controller.Standard.RT]).peek().to(rightController.triggerPress); mapping.from([Controller.Standard.RTClick]).peek().to(rightController.triggerClick); mapping.from([Controller.Standard.LT]).peek().to(leftController.triggerPress); mapping.from([Controller.Standard.LTClick]).peek().to(leftController.triggerClick); mapping.from([Controller.Standard.RB]).peek().to(rightController.secondaryPress); mapping.from([Controller.Standard.LB]).peek().to(leftController.secondaryPress); mapping.from([Controller.Standard.LeftGrip]).peek().to(leftController.secondaryPress); mapping.from([Controller.Standard.RightGrip]).peek().to(rightController.secondaryPress); mapping.from([Controller.Standard.LeftPrimaryThumb]).peek().to(leftController.thumbPress); mapping.from([Controller.Standard.RightPrimaryThumb]).peek().to(rightController.thumbPress); Controller.enableMapping(MAPPING_NAME); // the section below allows the grab script to listen for messages // that disable either one or both hands. useful for two handed items var handToDisable = 'none'; function update(deltaTime) { var timestamp = Date.now(); if (handToDisable !== LEFT_HAND && handToDisable !== 'both') { leftController.update(deltaTime, timestamp); } else { leftController.release(); } if (handToDisable !== RIGHT_HAND && handToDisable !== 'both') { rightController.update(deltaTime, timestamp); } else { rightController.release(); } equipHotspotBuddy.update(deltaTime, timestamp); entityPropertiesCache.update(); } Messages.subscribe('Hifi-Grab-Disable'); Messages.subscribe('Hifi-Hand-Disabler'); Messages.subscribe('Hifi-Hand-Grab'); Messages.subscribe('Hifi-Hand-RayPick-Blacklist'); Messages.subscribe('Hifi-Object-Manipulation'); var handleHandMessages = function(channel, message, sender) { var data; if (sender === MyAvatar.sessionUUID) { if (channel === 'Hifi-Hand-Disabler') { if (message === 'left') { handToDisable = LEFT_HAND; leftController.turnOffVisualizations(); } if (message === 'right') { handToDisable = RIGHT_HAND; rightController.turnOffVisualizations(); } if (message === 'both' || message === 'none') { if (message === 'both') { rightController.turnOffVisualizations(); leftController.turnOffVisualizations(); } handToDisable = message; } } else if (channel === 'Hifi-Grab-Disable') { data = JSON.parse(message); if (data.holdEnabled !== undefined) { print("holdEnabled: ", data.holdEnabled); holdEnabled = data.holdEnabled; } if (data.nearGrabEnabled !== undefined) { print("nearGrabEnabled: ", data.nearGrabEnabled); nearGrabEnabled = data.nearGrabEnabled; } if (data.farGrabEnabled !== undefined) { print("farGrabEnabled: ", data.farGrabEnabled); farGrabEnabled = data.farGrabEnabled; } } else if (channel === 'Hifi-Hand-Grab') { try { data = JSON.parse(message); var selectedController = (data.hand === 'left') ? leftController : rightController; var hotspotIndex = data.hotspotIndex !== undefined ? parseInt(data.hotspotIndex) : 0; selectedController.release(); var wearableEntity = data.entityID; entityPropertiesCache.addEntity(wearableEntity); selectedController.grabbedEntity = wearableEntity; var hotspots = selectedController.collectEquipHotspots(selectedController.grabbedEntity); if (hotspots.length > 0) { if (hotspotIndex >= hotspots.length) { hotspotIndex = 0; } selectedController.grabbedHotspot = hotspots[hotspotIndex]; } selectedController.setState(STATE_HOLD, "Hifi-Hand-Grab msg received"); selectedController.nearGrabbingEnter(); } catch (e) { print("WARNING: handControllerGrab.js -- error parsing Hifi-Hand-Grab message: " + message); } } else if (channel === 'Hifi-Hand-RayPick-Blacklist') { try { data = JSON.parse(message); var action = data.action; var id = data.id; var index = blacklist.indexOf(id); if (action === 'add' && index === -1) { blacklist.push(id); } if (action === 'remove') { if (index > -1) { blacklist.splice(index, 1); } } } catch (e) { print("WARNING: handControllerGrab.js -- error parsing Hifi-Hand-RayPick-Blacklist message: " + message); } } } }; Messages.messageReceived.connect(handleHandMessages); var BASIC_TIMER_INTERVAL = 20; // 20ms = 50hz good enough var updateIntervalTimer = Script.setInterval(function(){ update(); }, BASIC_TIMER_INTERVAL); function cleanup() { Script.clearInterval(updateIntervalTimer); rightController.cleanup(); leftController.cleanup(); Controller.disableMapping(MAPPING_NAME); Reticle.setVisible(true); } Script.scriptEnding.connect(cleanup); }()); // END LOCAL_SCOPE