"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 getEntityCustomData, flatten, Xform, Script, Quat, Vec3, MyAvatar, Entities, Overlays, Settings, Reticle, Controller, Camera, Messages, Mat4, getControllerWorldLocation, getGrabPointSphereOffset, setGrabCommunications, Menu, HMD, isInEditMode */ /* eslint indent: ["error", 4, { "outerIIFEBody": 0 }] */ (function() { // BEGIN LOCAL_SCOPE Script.include("/~/system/libraries/utils.js"); Script.include("/~/system/libraries/Xform.js"); Script.include("/~/system/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 = false; // // 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 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; // triggered when stylus presses a web overlay/entity var HAPTIC_STYLUS_STRENGTH = 1.0; var HAPTIC_STYLUS_DURATION = 20.0; // triggerd when ui laser presses a web overlay/entity var HAPTIC_LASER_UI_STRENGTH = 1.0; var HAPTIC_LASER_UI_DURATION = 20.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; var WEB_DISPLAY_STYLUS_DISTANCE = 0.5; var WEB_STYLUS_LENGTH = 0.2; var WEB_TOUCH_Y_OFFSET = 0.05; // how far forward (or back with a negative number) to slide stylus in hand var WEB_TOUCH_TOO_CLOSE = 0.03; // if the stylus is pushed far though the web surface, don't consider it touching var WEB_TOUCH_Y_TOUCH_DEADZONE_SIZE = 0.01; var FINGER_TOUCH_Y_OFFSET = -0.02; var FINGER_TOUCH_MIN = -0.01 - FINGER_TOUCH_Y_OFFSET; var FINGER_TOUCH_MAX = 0.01 - FINGER_TOUCH_Y_OFFSET; // // 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.1; // 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}"; var AVATAR_SELF_ID = "{00000000-0000-0000-0000-000000000001}"; var DEFAULT_REGISTRATION_POINT = { x: 0.5, y: 0.5, z: 0.5 }; var INCHES_TO_METERS = 1.0 / 39.3701; // 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 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"]; var holdEnabled = true; var nearGrabEnabled = true; var farGrabEnabled = true; var myAvatarScalingEnabled = true; var objectScalingEnabled = true; var mostRecentSearchingHand = RIGHT_HAND; var DEFAULT_SPHERE_MODEL_URL = "http://hifi-content.s3.amazonaws.com/alan/dev/equip-Fresnel-3.fbx"; var HARDWARE_MOUSE_ID = 0; // Value reserved for hardware mouse. // states for the state machine var STATE_OFF = 0; var STATE_SEARCHING = 1; var STATE_DISTANCE_HOLDING = 2; var STATE_DISTANCE_ROTATING = 3; var STATE_NEAR_GRABBING = 4; var STATE_NEAR_TRIGGER = 5; var STATE_FAR_TRIGGER = 6; var STATE_HOLD = 7; var STATE_ENTITY_STYLUS_TOUCHING = 8; var STATE_ENTITY_LASER_TOUCHING = 9; var STATE_OVERLAY_STYLUS_TOUCHING = 10; var STATE_OVERLAY_LASER_TOUCHING = 11; var CONTROLLER_STATE_MACHINE = {}; 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_DISTANCE_ROTATING] = { name: "distance_rotating", enterMethod: "distanceRotatingEnter", updateMethod: "distanceRotating" }; 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_STYLUS_TOUCHING] = { name: "entityStylusTouching", enterMethod: "entityTouchingEnter", exitMethod: "entityTouchingExit", updateMethod: "entityTouching" }; CONTROLLER_STATE_MACHINE[STATE_ENTITY_LASER_TOUCHING] = { name: "entityLaserTouching", enterMethod: "entityTouchingEnter", exitMethod: "entityTouchingExit", updateMethod: "entityTouching" }; CONTROLLER_STATE_MACHINE[STATE_OVERLAY_STYLUS_TOUCHING] = { name: "overlayStylusTouching", enterMethod: "overlayTouchingEnter", exitMethod: "overlayTouchingExit", updateMethod: "overlayTouching" }; CONTROLLER_STATE_MACHINE[STATE_OVERLAY_LASER_TOUCHING] = { name: "overlayLaserTouching", enterMethod: "overlayTouchingEnter", exitMethod: "overlayTouchingExit", updateMethod: "overlayTouching" }; function getFingerWorldLocation(hand) { var fingerJointName = (hand === RIGHT_HAND) ? "RightHandIndex4" : "LeftHandIndex4"; var fingerJointIndex = MyAvatar.getJointIndex(fingerJointName); var fingerPosition = MyAvatar.getAbsoluteJointTranslationInObjectFrame(fingerJointIndex); var fingerRotation = MyAvatar.getAbsoluteJointRotationInObjectFrame(fingerJointIndex); var worldFingerRotation = Quat.multiply(MyAvatar.orientation, fingerRotation); var worldFingerPosition = Vec3.sum(MyAvatar.position, Vec3.multiplyQbyV(MyAvatar.orientation, fingerPosition)); // local y offset. var localYOffset = Vec3.multiplyQbyV(worldFingerRotation, {x: 0, y: FINGER_TOUCH_Y_OFFSET, z: 0}); var offsetWorldFingerPosition = Vec3.sum(worldFingerPosition, localYOffset); return { position: offsetWorldFingerPosition, orientation: worldFingerRotation, rotation: worldFingerRotation, valid: true }; } // Object assign polyfill if (typeof Object.assign != 'function') { Object.assign = function(target, varArgs) { 'use strict'; if (target == null) { throw new TypeError('Cannot convert undefined or null to object'); } var to = Object(target); for (var index = 1; index < arguments.length; index++) { var nextSource = arguments[index]; if (nextSource != null) { for (var nextKey in nextSource) { if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { to[nextKey] = nextSource[nextKey]; } } } } return to; }; } 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 projectOntoXYPlane(worldPos, position, rotation, dimensions, registrationPoint) { var invRot = Quat.inverse(rotation); var localPos = Vec3.multiplyQbyV(invRot, Vec3.subtract(worldPos, position)); var invDimensions = { x: 1 / dimensions.x, y: 1 / dimensions.y, z: 1 / dimensions.z }; var normalizedPos = Vec3.sum(Vec3.multiplyVbyV(localPos, invDimensions), registrationPoint); return { x: normalizedPos.x * dimensions.x, y: (1 - normalizedPos.y) * dimensions.y }; // flip y-axis } function projectOntoEntityXYPlane(entityID, worldPos) { var props = entityPropertiesCache.getProps(entityID); return projectOntoXYPlane(worldPos, props.position, props.rotation, props.dimensions, props.registrationPoint); } function projectOntoOverlayXYPlane(overlayID, worldPos) { var position = Overlays.getProperty(overlayID, "position"); var rotation = Overlays.getProperty(overlayID, "rotation"); var dimensions; var dpi = Overlays.getProperty(overlayID, "dpi"); if (dpi) { // Calculate physical dimensions for web3d overlay from resolution and dpi; "dimensions" property is used as a scale. var resolution = Overlays.getProperty(overlayID, "resolution"); resolution.z = 1; // Circumvent divide-by-zero. var scale = Overlays.getProperty(overlayID, "dimensions"); scale.z = 0.01; // overlay dimensions are 2D, not 3D. dimensions = Vec3.multiplyVbyV(Vec3.multiply(resolution, INCHES_TO_METERS / dpi), scale); } else { dimensions = Overlays.getProperty(overlayID, "dimensions"); dimensions.z = 0.01; // overlay dimensions are 2D, not 3D. } return projectOntoXYPlane(worldPos, position, rotation, dimensions, DEFAULT_REGISTRATION_POINT); } function handLaserIntersectItem(position, rotation, start) { var worldHandPosition = start.position; var worldHandRotation = start.orientation; if (position) { var planePosition = position; var planeNormal = Vec3.multiplyQbyV(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 handLaserIntersectEntity(entityID, start) { var props = entityPropertiesCache.getProps(entityID); return handLaserIntersectItem(props.position, props.rotation, start); } function handLaserIntersectOverlay(overlayID, start) { var position = Overlays.getProperty(overlayID, "position"); var rotation = Overlays.getProperty(overlayID, "rotation"); return handLaserIntersectItem(position, rotation, start); } 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, precise, [], [HMD.tabletID]); 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; } var GRAB_PREFIX_LENGTH = 5; var UUID_LENGTH = 38; if (tag && tag.slice(0, GRAB_PREFIX_LENGTH) == "grab-") { // we see a grab-*uuid* shaped tag and it's not ours, so someone else is grabbing it. return tag.slice(GRAB_PREFIX_LENGTH, GRAB_PREFIX_LENGTH + UUID_LENGTH - 1); } } return null; } 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); } // 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; function isEditing() { return EXTERNALLY_MANAGED_2D_MINOR_MODE && isInEditMode(); } 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", { name: "hotspot overlay", 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; this.stylus = null; this.homeButtonTouched = false; this.editTriggered = false; this.controllerJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "_CONTROLLER_RIGHTHAND" : "_CONTROLLER_LEFTHAND"); // Until there is some reliable way to keep track of a "stack" of parentIDs, we'll have problems // when more than one avatar does parenting grabs on things. This script tries to work // around this with two associative arrays: previousParentID and previousParentJointIndex. If // (1) avatar-A does a parenting grab on something, and then (2) avatar-B takes it, and (3) avatar-A // releases it and then (4) avatar-B releases it, then avatar-B will set the parent back to // avatar-A's hand. Avatar-A is no longer grabbing it, so it will end up triggering avatar-A's // checkForUnexpectedChildren which will put it back to wherever it was when avatar-A initially grabbed it. // this will work most of the time, unless avatar-A crashes or logs out while avatar-B is grabbing the // entity. This can also happen when a single avatar passes something from hand to hand. this.previousParentID = {}; this.previousParentJointIndex = {}; this.previouslyUnhooked = {}; this.shouldScale = false; this.isScalingAvatar = 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.grabbedThingID = null; // on this entity. this.grabbedOverlay = null; 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; this.searchSphere = null; this.otherGrabbingLine = null; this.otherGrabbingUUID = 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 = {}; this.tabletStabbed = false; this.tabletStabbedPos2D = null; this.tabletStabbedPos3D = null; this.useFingerInsteadOfStylus = false; 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(); this.maybeScaleMyAvatar(); var DEFAULT_USE_FINGER_AS_STYLUS = false; var USE_FINGER_AS_STYLUS = Settings.getValue("preferAvatarFingerOverStylus"); if (USE_FINGER_AS_STYLUS === "") { USE_FINGER_AS_STYLUS = DEFAULT_USE_FINGER_AS_STYLUS; } if (USE_FINGER_AS_STYLUS && MyAvatar.getJointIndex("LeftHandIndex4") !== -1) { this.useFingerInsteadOfStylus = true; } else { this.useFingerInsteadOfStylus = false; } if (this.ignoreInput()) { // Most hand input is disabled, because we are interacting with the 2d hud. // However, we still should check for collisions of the stylus with the web overlay. var controllerLocation = getControllerWorldLocation(this.handToController(), true); this.processStylus(controllerLocation.position); 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) { if (this.grabbedIsOverlay) { return; } var args = [this.hand === RIGHT_HAND ? "right" : "left", MyAvatar.sessionUUID]; Entities.callEntityMethod(this.grabbedThingID, entityMethodName, args); }; this.setState = function(newState, reason) { if ((isInEditMode() && this.grabbedThingID !== HMD.tabletID) && (newState !== STATE_OFF && newState !== STATE_SEARCHING && newState !== STATE_OVERLAY_STYLUS_TOUCHING && newState !== STATE_OVERLAY_LASER_TOUCHING)) { return; } setGrabCommunications((newState === STATE_DISTANCE_HOLDING) || (newState === STATE_DISTANCE_ROTATING) || (newState === STATE_NEAR_GRABBING)); if (WANT_DEBUG || WANT_DEBUG_STATE) { var oldStateName = stateToName(this.state); var newStateName = stateToName(newState); print("STATE (" + this.hand + "): " + this.state + "-" + 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 (!this.grabPointSphere) { this.grabPointSphere = Overlays.addOverlay("sphere", { name: "grabPointSphere", localPosition: getGrabPointSphereOffset(this.handToController()), localRotation: { x: 0, y: 0, z: 0, w: 1 }, dimensions: GRAB_POINT_SPHERE_RADIUS * 2, color: GRAB_POINT_SPHERE_COLOR, alpha: GRAB_POINT_SPHERE_ALPHA, solid: true, visible: true, ignoreRayIntersection: true, drawInFront: false, parentID: AVATAR_SELF_ID, parentJointIndex: this.controllerJointIndex }); } }; 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 = { name: "searchSphere", 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, ignoreRayIntersection: true }); } }; this.showStylus = function() { if (this.stylus) { return; } var stylusProperties = { name: "stylus", url: Script.resourcesPath() + "meshes/tablet-stylus-fat.fbx", localPosition: Vec3.sum({ x: 0.0, y: WEB_TOUCH_Y_OFFSET, z: 0.0 }, getGrabPointSphereOffset(this.handToController())), localRotation: Quat.fromVec3Degrees({ x: -90, y: 0, z: 0 }), dimensions: { x: 0.01, y: 0.01, z: WEB_STYLUS_LENGTH }, solid: true, visible: true, ignoreRayIntersection: true, drawInFront: false, parentID: AVATAR_SELF_ID, parentJointIndex: MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "_CAMERA_RELATIVE_CONTROLLER_RIGHTHAND" : "_CAMERA_RELATIVE_CONTROLLER_LEFTHAND") }; this.stylus = Overlays.addOverlay("model", stylusProperties); }; this.hideStylus = function() { if (!this.stylus) { return; } Overlays.deleteOverlay(this.stylus); this.stylus = null; if (this.stylusTip) { Overlays.deleteOverlay(this.stylusTip); this.stylusTip = null; } }; this.overlayLineOn = function(closePoint, farPoint, color, farParentID) { if (this.overlayLine === null) { var lineProperties = { name: "line", glow: 1.0, lineWidth: 5, 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, parentID: AVATAR_SELF_ID, parentJointIndex: this.controllerJointIndex, endParentID: farParentID }; this.overlayLine = Overlays.addOverlay("line3d", lineProperties); } else { if (farParentID && farParentID != NULL_UUID) { Overlays.editOverlay(this.overlayLine, { color: color, endParentID: farParentID }); } else { Overlays.editOverlay(this.overlayLine, { length: Vec3.distance(farPoint, closePoint), color: color, endParentID: farParentID }); } } }; 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.otherGrabbingLineOn = function(avatarPosition, entityPosition, color) { if (this.otherGrabbingLine === null) { var lineProperties = { lineWidth: 5, start: avatarPosition, end: entityPosition, color: color, glow: 1.0, ignoreRayIntersection: true, drawInFront: true, visible: true, alpha: 1 }; this.otherGrabbingLine = Overlays.addOverlay("line3d", lineProperties); } else { Overlays.editOverlay(this.otherGrabbingLine, { start: avatarPosition, end: entityPosition, color: color }); } }; 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.otherGrabbingLineOff = function() { if (this.otherGrabbingLine !== null) { Overlays.deleteOverlay(this.otherGrabbingLine); } this.otherGrabbingLine = null; }; this.turnOffVisualizations = function() { this.overlayLineOff(); this.grabPointSphereOff(); this.lineOff(); this.searchSphereOff(); this.otherGrabbingLineOff(); restore2DMode(); }; this.triggerPress = function(value) { _this.rawTriggerValue = value; }; this.triggerClick = function(value) { _this.triggerClicked = value; }; this.secondaryPress = function(value) { _this.rawSecondaryValue = value; }; 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.processStylus = function(worldHandPosition) { var performRayTest = false; if (this.useFingerInsteadOfStylus) { this.hideStylus(); performRayTest = true; } else { var i; // see if the hand is near a tablet or web-entity var candidateEntities = Entities.findEntities(worldHandPosition, WEB_DISPLAY_STYLUS_DISTANCE); entityPropertiesCache.addEntities(candidateEntities); for (i = 0; i < candidateEntities.length; i++) { var props = entityPropertiesCache.getProps(candidateEntities[i]); if (props && (props.type == "Web" || this.isTablet(candidateEntities[i]))) { performRayTest = true; break; } } if (!performRayTest) { var candidateOverlays = Overlays.findOverlays(worldHandPosition, WEB_DISPLAY_STYLUS_DISTANCE); for (i = 0; i < candidateOverlays.length; i++) { if (this.isTablet(candidateOverlays[i])) { performRayTest = true; break; } } } if (performRayTest) { this.showStylus(); } else { this.hideStylus(); } } if (performRayTest) { var rayPickInfo = this.calcRayPickInfo(this.hand, this.useFingerInsteadOfStylus); var max, min; if (this.useFingerInsteadOfStylus) { max = FINGER_TOUCH_MAX; min = FINGER_TOUCH_MIN; } else { max = WEB_STYLUS_LENGTH / 2.0 + WEB_TOUCH_Y_OFFSET; min = WEB_STYLUS_LENGTH / 2.0 + WEB_TOUCH_TOO_CLOSE; } if (rayPickInfo.distance < max && rayPickInfo.distance > min) { this.handleStylusOnHomeButton(rayPickInfo); if (this.handleStylusOnWebEntity(rayPickInfo)) { return; } if (this.handleStylusOnWebOverlay(rayPickInfo)) { return; } } else { this.homeButtonTouched = false; } } }; this.off = function(deltaTime, timestamp) { this.checkForUnexpectedChildren(); if (this.editTriggered) { this.editTriggered = false; } if (this.triggerSmoothedReleased() && this.secondaryReleased()) { this.waitForTriggerRelease = false; } if (!this.waitForTriggerRelease && (this.triggerSmoothedSqueezed() || this.secondarySqueezed())) { this.lastPickTime = 0; this.startingHandRotation = getControllerWorldLocation(this.handToController(), true).orientation; this.searchStartTime = Date.now(); 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) { // don't do haptic pulse for tablet var nonTabletEntities = grabbableEntities.filter(function(entityID) { return entityID != HMD.tabletID && entityID != HMD.homeButtonID; }); if (nonTabletEntities.length > 0) { Controller.triggerHapticPulse(1, 20, this.hand); } this.grabPointIntersectsEntity = true; this.grabPointSphereOn(); } } else { this.grabPointIntersectsEntity = false; this.grabPointSphereOff(); } this.processStylus(worldHandPosition); }; this.handleStylusOnHomeButton = function(rayPickInfo) { if (rayPickInfo.overlayID) { var homeButton = rayPickInfo.overlayID; var hmdHomeButton = HMD.homeButtonID; if (homeButton === hmdHomeButton) { if (this.homeButtonTouched === false) { this.homeButtonTouched = true; Controller.triggerHapticPulse(HAPTIC_STYLUS_STRENGTH, HAPTIC_STYLUS_DURATION, this.hand); Messages.sendLocalMessage("home", homeButton); } } else { this.homeButtonTouched = false; } } else { this.homeButtonTouched = false; } }; this.handleLaserOnHomeButton = function(rayPickInfo) { if (rayPickInfo.overlayID && this.triggerSmoothedGrab()) { var homeButton = rayPickInfo.overlayID; var hmdHomeButton = HMD.homeButtonID; if (homeButton === hmdHomeButton) { if (this.homeButtonTouched === false) { this.homeButtonTouched = true; Controller.triggerHapticPulse(HAPTIC_LASER_UI_STRENGTH, HAPTIC_LASER_UI_DURATION, this.hand); Messages.sendLocalMessage("home", homeButton); } } else { this.homeButtonTouched = false; } } else { this.homeButtonTouched = false; } }; 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; }; // Performs ray pick test from the hand controller into the world // @param {number} which hand to use, RIGHT_HAND or LEFT_HAND // @param {bool} if true use the world position/orientation of the index finger to cast the ray from. // @returns {object} returns object with two keys entityID and distance // this.calcRayPickInfo = function(hand, useFingerInsteadOfController) { var controllerLocation; if (useFingerInsteadOfController) { controllerLocation = getFingerWorldLocation(hand); } else { 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, overlayID: 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, properties: intersection.properties }; } 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 debug = (WANT_DEBUG_SEARCH_NAME && props.name === WANT_DEBUG_SEARCH_NAME); var otherHandControllerState = this.getOtherHandController().state; var okToEquipFromOtherHand = ((otherHandControllerState === STATE_NEAR_GRABBING || otherHandControllerState === STATE_DISTANCE_HOLDING || otherHandControllerState === STATE_DISTANCE_ROTATING) && this.getOtherHandController().grabbedThingID === hotspot.entityID); var hasParent = true; if (props.parentID === NULL_UUID) { hasParent = false; } if ((hasParent || entityHasActions(hotspot.entityID)) && !okToEquipFromOtherHand) { if (debug) { print("equip is skipping '" + props.name + "': grabbed by someone else"); } return false; } return true; }; this.entityIsCloneable = function(entityID) { var entityProps = entityPropertiesCache.getGrabbableProps(entityID); var props = entityPropertiesCache.getProps(entityID); if (!props) { return false; } if (entityProps.hasOwnProperty("cloneable")) { return entityProps.cloneable; } return false; } this.entityIsGrabbable = function(entityID) { var grabbableProps = entityPropertiesCache.getGrabbableProps(entityID); var props = entityPropertiesCache.getProps(entityID); if (!props) { return false; } var debug = (WANT_DEBUG_SEARCH_NAME && props.name === WANT_DEBUG_SEARCH_NAME); var grabbable = propsArePhysical(props); if (grabbableProps.hasOwnProperty("grabbable")) { 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; } this.otherGrabbingUUID = entityIsGrabbedByOther(entityID); if (this.otherGrabbingUUID !== null) { // 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.entityIsCloneable(entityID) && !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; var FAR_SEARCH_DELAY = 0; // msecs before search beam appears var farSearching = this.triggerSmoothedSqueezed() && (Date.now() - this.searchStartTime > FAR_SEARCH_DELAY); this.grabbedThingID = null; this.grabbedOverlay = null; this.isInitialGrab = false; this.preparingHoldRelease = false; this.checkForUnexpectedChildren(); if ((this.triggerSmoothedReleased() && this.secondaryReleased())) { this.grabbedThingID = null; 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() || this.secondarySqueezed()) && holdEnabled) { this.grabbedHotspot = potentialEquipHotspot; this.grabbedThingID = potentialEquipHotspot.entityID; this.grabbedIsOverlay = false; this.setState(STATE_HOLD, "equipping '" + entityPropertiesCache.getProps(this.grabbedThingID).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); }); var candidateOverlays = Overlays.findOverlays(handPosition, NEAR_GRAB_RADIUS); var grabbableOverlays = candidateOverlays.filter(function(overlayID) { return Overlays.getProperty(overlayID, "grabbable"); }); 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; } if (grabbableOverlays.length > 0) { grabbableOverlays.sort(function(a, b) { var aPosition = Overlays.getProperty(a, "position"); var aDistance = Vec3.distance(aPosition, handPosition); var bPosition = Overlays.getProperty(b, "position"); var bDistance = Vec3.distance(bPosition, handPosition); return aDistance - bDistance; }); this.grabbedThingID = grabbableOverlays[0]; this.grabbedIsOverlay = true; if ((this.triggerSmoothedGrab() || this.secondarySqueezed()) && nearGrabEnabled) { this.setState(STATE_NEAR_GRABBING, "near grab overlay '" + Overlays.getProperty(this.grabbedThingID, "name") + "'"); return; } } 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]; if (!isInEditMode() || entity == HMD.tabletID) { // tablet is grabbable, even when editing name = entityPropertiesCache.getProps(entity).name; this.grabbedThingID = entity; this.grabbedIsOverlay = false; if (this.entityWantsTrigger(entity)) { if (this.triggerSmoothedGrab()) { this.setState(STATE_NEAR_TRIGGER, "near trigger '" + name + "'"); return; } } else { // If near something grabbable, grab it! if ((this.triggerSmoothedGrab() || this.secondarySqueezed()) && nearGrabEnabled) { this.setState(STATE_NEAR_GRABBING, "near grab entity '" + name + "'"); return; } } } } if (rayPickInfo.distance >= WEB_STYLUS_LENGTH / 2.0 + WEB_TOUCH_Y_OFFSET) { this.handleLaserOnHomeButton(rayPickInfo); if (this.handleLaserOnWebEntity(rayPickInfo)) { return; } if (this.handleLaserOnWebOverlay(rayPickInfo)) { return; } } if (isInEditMode()) { this.searchIndicatorOn(rayPickInfo.searchRay); if (this.triggerSmoothedGrab()) { if (!this.editTriggered && rayPickInfo.entityID) { Messages.sendLocalMessage("entityToolUpdates", JSON.stringify({ method: "selectEntity", entityID: rayPickInfo.entityID })); } this.editTriggered = true; } Reticle.setVisible(false); return; } if (rayPickInfo.entityID) { entity = rayPickInfo.entityID; name = entityPropertiesCache.getProps(entity).name; if (this.entityWantsTrigger(entity)) { if (this.triggerSmoothedGrab()) { this.grabbedThingID = entity; this.grabbedIsOverlay = false; this.setState(STATE_FAR_TRIGGER, "far trigger '" + name + "'"); return; } else { // potentialFarTriggerEntity = entity; } this.otherGrabbingLineOff(); } else if (this.entityIsDistanceGrabbable(rayPickInfo.entityID, handPosition)) { if (this.triggerSmoothedGrab() && !isEditing() && farGrabEnabled && farSearching) { this.grabbedThingID = entity; this.grabbedIsOverlay = false; this.grabbedDistance = rayPickInfo.distance; if (this.getOtherHandController().state === STATE_DISTANCE_HOLDING) { this.setState(STATE_DISTANCE_ROTATING, "distance rotate '" + name + "'"); } else { this.setState(STATE_DISTANCE_HOLDING, "distance hold '" + name + "'"); } return; } else { // potentialFarGrabEntity = entity; } this.otherGrabbingLineOff(); } else if (this.otherGrabbingUUID !== null) { if (this.triggerSmoothedGrab() && !isEditing() && farGrabEnabled && farSearching) { var avatar = AvatarList.getAvatar(this.otherGrabbingUUID); var IN_FRONT_OF_AVATAR = { x: 0, y: 0.2, z: 0.4 }; // Up from hips and in front of avatar. var startPosition = Vec3.sum(avatar.position, Vec3.multiplyQbyV(avatar.rotation, IN_FRONT_OF_AVATAR)); var finishPisition = Vec3.sum(rayPickInfo.properties.position, // Entity's centroid. Vec3.multiplyQbyV(rayPickInfo.properties.rotation , Vec3.multiplyVbyV(rayPickInfo.properties.dimensions, Vec3.subtract(DEFAULT_REGISTRATION_POINT, rayPickInfo.properties.registrationPoint)))); this.otherGrabbingLineOn(startPosition, finishPisition, COLORS_GRAB_DISTANCE_HOLD); } else { this.otherGrabbingLineOff(); } } else { this.otherGrabbingLineOff(); } } else { this.otherGrabbingLineOff(); } this.updateEquipHaptics(potentialEquipHotspot, handPosition); var nearEquipHotspots = this.chooseNearEquipHotspots(candidateEntities, EQUIP_HOTSPOT_RENDER_RADIUS); equipHotspotBuddy.updateHotspots(nearEquipHotspots, timestamp); if (potentialEquipHotspot) { equipHotspotBuddy.highlightHotspot(potentialEquipHotspot); } if (farGrabEnabled && farSearching) { this.searchIndicatorOn(rayPickInfo.searchRay); } Reticle.setVisible(false); }; this.isTablet = function (entityID) { if (entityID === HMD.tabletID) { return true; } return false; }; this.handleStylusOnWebEntity = function (rayPickInfo) { var pointerEvent; if (rayPickInfo.entityID && Entities.wantsHandControllerPointerEvents(rayPickInfo.entityID)) { var entity = rayPickInfo.entityID; var name = entityPropertiesCache.getProps(entity).name; if (Entities.keyboardFocusEntity != entity) { Overlays.keyboardFocusOverlay = 0; 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_STYLUS_TOUCHING && this.getOtherHandController().state !== STATE_ENTITY_LASER_TOUCHING && this.getOtherHandController().state !== STATE_OVERLAY_STYLUS_TOUCHING && this.getOtherHandController().state !== STATE_OVERLAY_LASER_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); } this.grabbedThingID = entity; this.grabbedIsOverlay = false; this.setState(STATE_ENTITY_STYLUS_TOUCHING, "begin touching entity '" + name + "'"); return true; } else if (this.hoverEntity) { pointerEvent = { type: "Move", id: this.hand + 1 }; Entities.sendHoverLeaveEntity(this.hoverEntity, pointerEvent); this.hoverEntity = null; } return false; }; this.handleStylusOnWebOverlay = function (rayPickInfo) { var pointerEvent; if (rayPickInfo.overlayID) { var overlay = rayPickInfo.overlayID; if (Overlays.getProperty(overlay, "type") != "web3d") { return false; } if (Overlays.keyboardFocusOverlay != overlay) { Entities.keyboardFocusEntity = null; Overlays.keyboardFocusOverlay = overlay; pointerEvent = { type: "Move", id: HARDWARE_MOUSE_ID, pos2D: projectOntoOverlayXYPlane(overlay, rayPickInfo.intersection), pos3D: rayPickInfo.intersection, normal: rayPickInfo.normal, direction: rayPickInfo.searchRay.direction, button: "None" }; this.hoverOverlay = overlay; Overlays.sendHoverEnterOverlay(overlay, 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_STYLUS_TOUCHING && this.getOtherHandController().state !== STATE_ENTITY_LASER_TOUCHING && this.getOtherHandController().state !== STATE_OVERLAY_STYLUS_TOUCHING && this.getOtherHandController().state !== STATE_OVERLAY_LASER_TOUCHING)) { // most recently searching hand has priority over other hand, for the purposes of button highlighting. pointerEvent = { type: "Move", id: HARDWARE_MOUSE_ID, pos2D: projectOntoOverlayXYPlane(overlay, rayPickInfo.intersection), pos3D: rayPickInfo.intersection, normal: rayPickInfo.normal, direction: rayPickInfo.searchRay.direction, button: "None" }; Overlays.sendMouseMoveOnOverlay(overlay, pointerEvent); Overlays.sendHoverOverOverlay(overlay, pointerEvent); } this.grabbedOverlay = overlay; this.setState(STATE_OVERLAY_STYLUS_TOUCHING, "begin touching overlay '" + overlay + "'"); return true; } else if (this.hoverOverlay) { pointerEvent = { type: "Move", id: HARDWARE_MOUSE_ID }; Overlays.sendHoverLeaveOverlay(this.hoverOverlay, pointerEvent); this.hoverOverlay = null; } return false; }; this.handleLaserOnWebEntity = function(rayPickInfo) { var pointerEvent; if (rayPickInfo.entityID && Entities.wantsHandControllerPointerEvents(rayPickInfo.entityID)) { var entity = rayPickInfo.entityID; var props = entityPropertiesCache.getProps(entity); var name = props.name; if (Entities.keyboardFocusEntity != entity) { Overlays.keyboardFocusOverlay = 0; 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_STYLUS_TOUCHING && this.getOtherHandController().state !== STATE_ENTITY_LASER_TOUCHING && this.getOtherHandController().state !== STATE_OVERLAY_STYLUS_TOUCHING && this.getOtherHandController().state !== STATE_OVERLAY_LASER_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.isTablet(entity))) { this.grabbedThingID = entity; this.grabbedIsOverlay = false; this.setState(STATE_ENTITY_LASER_TOUCHING, "begin touching entity '" + name + "'"); return true; } } else if (this.hoverEntity) { pointerEvent = { type: "Move", id: this.hand + 1 }; Entities.sendHoverLeaveEntity(this.hoverEntity, pointerEvent); this.hoverEntity = null; } return false; }; this.handleLaserOnWebOverlay = function(rayPickInfo) { var pointerEvent; var overlay; if (rayPickInfo.overlayID) { overlay = rayPickInfo.overlayID; if (Overlays.keyboardFocusOverlay != overlay) { Entities.keyboardFocusEntity = null; Overlays.keyboardFocusOverlay = overlay; pointerEvent = { type: "Move", id: HARDWARE_MOUSE_ID, pos2D: projectOntoOverlayXYPlane(overlay, rayPickInfo.intersection), pos3D: rayPickInfo.intersection, normal: rayPickInfo.normal, direction: rayPickInfo.searchRay.direction, button: "None" }; this.hoverOverlay = overlay; Overlays.sendHoverEnterOverlay(overlay, 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_STYLUS_TOUCHING && this.getOtherHandController().state !== STATE_ENTITY_LASER_TOUCHING && this.getOtherHandController().state !== STATE_OVERLAY_STYLUS_TOUCHING && this.getOtherHandController().state !== STATE_OVERLAY_LASER_TOUCHING)) { // most recently searching hand has priority over other hand, for the purposes of button highlighting. pointerEvent = { type: "Move", id: HARDWARE_MOUSE_ID, pos2D: projectOntoOverlayXYPlane(overlay, rayPickInfo.intersection), pos3D: rayPickInfo.intersection, normal: rayPickInfo.normal, direction: rayPickInfo.searchRay.direction, button: "None" }; Overlays.sendMouseMoveOnOverlay(overlay, pointerEvent); Overlays.sendHoverOverOverlay(overlay, pointerEvent); } if (this.triggerSmoothedGrab()) { this.grabbedOverlay = overlay; this.setState(STATE_OVERLAY_LASER_TOUCHING, "begin touching overlay '" + overlay + "'"); return true; } } else if (this.hoverOverlay) { pointerEvent = { type: "Move", id: HARDWARE_MOUSE_ID }; Overlays.sendHoverLeaveOverlay(this.hoverOverlay, pointerEvent); this.hoverOverlay = null; } return 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.ensureDynamic = function () { // 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 props = Entities.getEntityProperties(this.grabbedThingID, ["velocity", "dynamic", "parentID"]); if (props.dynamic && props.parentID == NULL_UUID) { var velocity = props.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.grabbedThingID, { velocity: velocity }); } } }; this.distanceHoldingEnter = function() { this.clearEquipHaptics(); this.grabPointSphereOff(); this.shouldScale = false; var controllerLocation = getControllerWorldLocation(this.handToController(), true); var worldControllerPosition = controllerLocation.position; var worldControllerRotation = controllerLocation.orientation; // transform the position into room space var worldToSensorMat = Mat4.inverse(MyAvatar.getSensorToWorldMatrix()); var roomControllerPosition = Mat4.transformPoint(worldToSensorMat, worldControllerPosition); var grabbedProperties = Entities.getEntityProperties(this.grabbedThingID, 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 = this.grabbedDistance; this.grabRadialVelocity = 0.0; // offset between controller vector at the grab radius and the entity position var targetPosition = Vec3.multiply(this.grabRadius, Quat.getUp(worldControllerRotation)); targetPosition = Vec3.sum(targetPosition, worldControllerPosition); this.offsetPosition = Vec3.subtract(this.currentObjectPosition, targetPosition); // compute a constant based on the initial conditions which we use below to exaggerate 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.grabbedThingID, { 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.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"); this.ensureDynamic(); this.setState(STATE_OFF, "trigger released"); if (this.getOtherHandController().state === STATE_DISTANCE_ROTATING) { this.getOtherHandController().setState(STATE_SEARCHING, "trigger released on holding controller"); // Can't set state of other controller to STATE_DISTANCE_HOLDING because then either: // (a) The entity would jump to line up with the formerly rotating controller's orientation, or // (b) The grab beam would need an orientation offset to the controller's true orientation. // Neither of these options is good, so instead set STATE_SEARCHING and subsequently let the formerly distance // rotating controller start distance holding the entity if it happens to be pointing at the entity. } return; } 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.grabbedThingID, 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 }; // 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); newTargetPosition = Vec3.sum(newTargetPosition, this.offsetPosition); var objectToAvatar = Vec3.subtract(this.currentObjectPosition, MyAvatar.position); var handControllerData = getEntityCustomData('handControllerKey', this.grabbedThingID, defaultMoveWithHeadData); 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); } } this.maybeScale(grabbedProperties); // visualizations var rayPickInfo = this.calcRayPickInfo(this.hand); this.overlayLineOn(rayPickInfo.searchRay.origin, Vec3.subtract(grabbedProperties.position, this.offsetPosition), COLORS_GRAB_DISTANCE_HOLD, this.grabbedThingID); var distanceToObject = Vec3.length(Vec3.subtract(MyAvatar.position, this.currentObjectPosition)); var success = Entities.updateAction(this.grabbedThingID, 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.distanceRotatingEnter = function() { this.clearEquipHaptics(); this.grabPointSphereOff(); var controllerLocation = getControllerWorldLocation(this.handToController(), true); var worldControllerPosition = controllerLocation.position; var worldControllerRotation = controllerLocation.orientation; var grabbedProperties = Entities.getEntityProperties(this.grabbedThingID, GRABBABLE_PROPERTIES); this.currentObjectPosition = grabbedProperties.position; this.grabRadius = this.grabbedDistance; // Offset between controller vector at the grab radius and the entity position. var targetPosition = Vec3.multiply(this.grabRadius, Quat.getUp(worldControllerRotation)); targetPosition = Vec3.sum(targetPosition, worldControllerPosition); this.offsetPosition = Vec3.subtract(this.currentObjectPosition, targetPosition); // Initial controller rotation. this.previousWorldControllerRotation = worldControllerRotation; Controller.triggerHapticPulse(HAPTIC_PULSE_STRENGTH, HAPTIC_PULSE_DURATION, this.hand); this.turnOffVisualizations(); }; this.distanceRotating = function(deltaTime, timestamp) { if (!this.triggerClicked) { this.callEntityMethodOnGrabbed("releaseGrab"); this.ensureDynamic(); this.setState(STATE_OFF, "trigger released"); return; } var grabbedProperties = Entities.getEntityProperties(this.grabbedThingID, GRABBABLE_PROPERTIES); // Delta rotation of grabbing controller since last update. var worldControllerRotation = getControllerWorldLocation(this.handToController(), true).orientation; var controllerRotationDelta = Quat.multiply(worldControllerRotation, Quat.inverse(this.previousWorldControllerRotation)); // Rotate entity by twice the delta rotation. controllerRotationDelta = Quat.multiply(controllerRotationDelta, controllerRotationDelta); // Perform the rotation in the translation controller's action update. this.getOtherHandController().currentObjectRotation = Quat.multiply(controllerRotationDelta, this.getOtherHandController().currentObjectRotation); // Rotate about the translation controller's target position. this.offsetPosition = Vec3.multiplyQbyV(controllerRotationDelta, this.offsetPosition); this.getOtherHandController().offsetPosition = Vec3.multiplyQbyV(controllerRotationDelta, this.getOtherHandController().offsetPosition); var rayPickInfo = this.calcRayPickInfo(this.hand); this.overlayLineOn(rayPickInfo.searchRay.origin, Vec3.subtract(grabbedProperties.position, this.offsetPosition), COLORS_GRAB_DISTANCE_HOLD, this.grabbedThingID); this.previousWorldControllerRotation = worldControllerRotation; } this.setupHoldAction = function() { this.actionID = Entities.addAction("hold", this.grabbedThingID, { 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.otherGrabbingLineOff(); this.dropGestureReset(); this.clearEquipHaptics(); this.shouldScale = false; Controller.triggerHapticPulse(HAPTIC_PULSE_STRENGTH, HAPTIC_PULSE_DURATION, this.hand); if (this.entityActivated) { var saveGrabbedID = this.grabbedThingID; this.release(); this.grabbedThingID = saveGrabbedID; } var grabbedProperties; if (this.grabbedIsOverlay) { grabbedProperties = { position: Overlays.getProperty(this.grabbedThingID, "position"), rotation: Overlays.getProperty(this.grabbedThingID, "rotation"), parentID: Overlays.getProperty(this.grabbedThingID, "parentID"), parentJointIndex: Overlays.getProperty(this.grabbedThingID, "parentJointIndex"), dynamic: false, shapeType: "none" }; this.ignoreIK = true; } else { grabbedProperties = Entities.getEntityProperties(this.grabbedThingID, GRABBABLE_PROPERTIES); if (FORCE_IGNORE_IK) { this.ignoreIK = true; } else { var grabbableData = getEntityCustomData(GRABBABLE_DATA_KEY, this.grabbedThingID, DEFAULT_GRABBABLE_DATA); 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); } // This boolean is used to check if the object that is grabbed has just been cloned // It is only set true, if the object that is grabbed creates a new clone. var isClone = false; var isPhysical = propsArePhysical(grabbedProperties) || (!this.grabbedIsOverlay && entityHasActions(this.grabbedThingID)); if (isPhysical && this.state == STATE_NEAR_GRABBING && grabbedProperties.parentID === NULL_UUID) { // grab entity via action if (!this.setupHoldAction()) { return; } Messages.sendMessage('Hifi-Object-Manipulation', JSON.stringify({ action: 'grab', grabbedEntity: this.grabbedThingID, joint: this.hand === RIGHT_HAND ? "RightHand" : "LeftHand" })); } else { // grab entity via parenting this.actionID = null; var handJointIndex; if (this.ignoreIK) { handJointIndex = this.controllerJointIndex; } else { handJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "RightHand" : "LeftHand"); } var reparentProps = { parentID: AVATAR_SELF_ID, 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; } if (this.grabbedIsOverlay) { Overlays.editOverlay(this.grabbedThingID, reparentProps); } else { if (grabbedProperties.userData.length > 0) { try{ var userData = JSON.parse(grabbedProperties.userData); var grabInfo = userData.grabbableKey; if (grabInfo && grabInfo.cloneable) { var worldEntities = Entities.findEntities(MyAvatar.position, 50); var count = 0; worldEntities.forEach(function(item) { var item = Entities.getEntityProperties(item, ["name"]); if (item.name.indexOf('-clone-' + grabbedProperties.id) !== -1) { count++; } }) var limit = grabInfo.cloneLimit ? grabInfo.cloneLimit : 0; if (count >= limit && limit !== 0) { delete limit; return; } var cloneableProps = Entities.getEntityProperties(grabbedProperties.id); cloneableProps.name = cloneableProps.name + '-clone-' + grabbedProperties.id; var lifetime = grabInfo.cloneLifetime ? grabInfo.cloneLifetime : 300; var dynamic = grabInfo.cloneDynamic ? grabInfo.cloneDynamic : false; var cUserData = Object.assign({}, userData); var cProperties = Object.assign({}, cloneableProps); isClone = true; delete cUserData.grabbableKey.cloneLifetime; delete cUserData.grabbableKey.cloneable; delete cUserData.grabbableKey.cloneDynamic; delete cUserData.grabbableKey.cloneLimit; delete cProperties.id cProperties.dynamic = dynamic; cProperties.locked = false; cUserData.grabbableKey.triggerable = true; cUserData.grabbableKey.grabbable = true; cProperties.lifetime = lifetime; cProperties.userData = JSON.stringify(cUserData); var cloneID = Entities.addEntity(cProperties); this.grabbedThingID = cloneID; grabbedProperties = Entities.getEntityProperties(cloneID); } }catch(e) {} } Entities.editEntity(this.grabbedThingID, reparentProps); } if (this.thisHandIsParent(grabbedProperties)) { // this should never happen, but if it does, don't set previous parent to be this hand. // this.previousParentID[this.grabbedThingID] = NULL; // this.previousParentJointIndex[this.grabbedThingID] = -1; } else { this.previousParentID[this.grabbedThingID] = grabbedProperties.parentID; this.previousParentJointIndex[this.grabbedThingID] = grabbedProperties.parentJointIndex; } Messages.sendMessage('Hifi-Object-Manipulation', JSON.stringify({ action: 'equip', grabbedEntity: this.grabbedThingID, joint: this.hand === RIGHT_HAND ? "RightHand" : "LeftHand" })); } if (!this.grabbedIsOverlay) { Entities.editEntity(this.grabbedThingID, { velocity: { x: 0, y: 0, z: 0 }, angularVelocity: { x: 0, y: 0, z: 0 }, // dynamic: false }); } var _this = this; /* * Setting context for function that is either called via timer or directly, depending if * if the object in question is a clone. If it is a clone, we need to make sure that the intial equipment event * is called correctly, as these just freshly created entity may not have completely initialized. */ var grabEquipCheck = function () { 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; } if (isClone) { // 100 ms seems to be sufficient time to force the check even occur after the object has been initialized. Script.setTimeout(grabEquipCheck, 100); } else { grabEquipCheck(); } }; this.nearGrabbing = function(deltaTime, timestamp) { this.grabPointSphereOff(); if (this.state == STATE_NEAR_GRABBING && (!this.triggerClicked && this.secondaryReleased())) { this.callEntityMethodOnGrabbed("releaseGrab"); this.setState(STATE_OFF, "trigger released"); return; } if (this.state == STATE_HOLD) { if (this.secondarySqueezed()) { // this.secondaryReleased() will always be true when not depressed // so we cannot simply rely on that for release - ensure that the // trigger was first "prepared" by being pushed in before the release this.preparingHoldRelease = true; } if (this.preparingHoldRelease && this.secondaryReleased()) { // we have an equipped object and the secondary trigger was released // short-circuit the other checks and release it this.preparingHoldRelease = false; this.callEntityMethodOnGrabbed("releaseEquip"); this.setState(STATE_OFF, "equipping ended via secondary press"); return; } 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()) { // store the offset attach points into preferences. if (USE_ATTACH_POINT_SETTINGS && this.grabbedHotspot && this.grabbedThingID) { var prefprops = Entities.getEntityProperties(this.grabbedThingID, ["localPosition", "localRotation"]); if (prefprops && prefprops.localPosition && prefprops.localRotation) { storeAttachPointForHotspotInSettings(this.grabbedHotspot, this.hand, prefprops.localPosition, prefprops.localRotation); } } var grabbedEntity = this.grabbedThingID; this.release(); this.grabbedThingID = grabbedEntity; this.setState(STATE_NEAR_GRABBING, "drop gesture detected"); return; } this.prevDropDetected = dropDetected; } var props; if (this.grabbedIsOverlay) { props = { localPosition: Overlays.getProperty(this.grabbedThingID, "localPosition"), parentID: Overlays.getProperty(this.grabbedThingID, "parentID"), parentJointIndex: Overlays.getProperty(this.grabbedThingID, "parentJointIndex"), position: Overlays.getProperty(this.grabbedThingID, "position"), rotation: Overlays.getProperty(this.grabbedThingID, "rotation"), dimensions: Overlays.getProperty(this.grabbedThingID, "dimensions"), registrationPoint: { x: 0.5, y: 0.5, z: 0.5 } }; } else { props = Entities.getEntityProperties(this.grabbedThingID, ["localPosition", "parentID", "parentJointIndex", "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; } if (this.state == STATE_NEAR_GRABBING && this.actionID === null && !this.thisHandIsParent(props)) { // someone took it from us or otherwise edited the parentID. end the grab. We don't do this // for equipped things so that they can be adjusted while equipped. this.callEntityMethodOnGrabbed("releaseGrab"); this.grabbedThingID = null; this.setState(STATE_OFF, "someone took it"); 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 == AVATAR_SELF_ID) { 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 += deltaTime; } else { this.autoUnequipCounter = 0; } if (this.autoUnequipCounter > 0.25) { // 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.state == STATE_NEAR_GRABBING) { this.maybeScale(props); } 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.grabbedThingID, 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.grabbedThingID, this.actionID); this.setupHoldAction(); } } }; this.maybeScale = function(props) { if (!objectScalingEnabled || this.isTablet(this.grabbedThingID) || this.grabbedIsOverlay) { return; } if (!this.shouldScale) { // If both secondary triggers squeezed, and the non-holding hand is empty, start scaling if (this.secondarySqueezed() && this.getOtherHandController().secondarySqueezed() && this.getOtherHandController().state === STATE_OFF) { this.scalingStartDistance = Vec3.length(Vec3.subtract(this.getHandPosition(), this.getOtherHandController().getHandPosition())); this.scalingStartDimensions = props.dimensions; this.shouldScale = true; } } else if (!this.secondarySqueezed() || !this.getOtherHandController().secondarySqueezed()) { this.shouldScale = false; } if (this.shouldScale) { var scalingCurrentDistance = Vec3.length(Vec3.subtract(this.getHandPosition(), this.getOtherHandController().getHandPosition())); var currentRescale = scalingCurrentDistance / this.scalingStartDistance; var newDimensions = Vec3.multiply(currentRescale, this.scalingStartDimensions); Entities.editEntity(this.grabbedThingID, { dimensions: newDimensions }); } }; this.maybeScaleMyAvatar = function() { if (!myAvatarScalingEnabled || this.shouldScale || this.hand === LEFT_HAND) { // If scaling disabled, or if we are currently scaling an entity, don't scale avatar // and only rescale avatar for one hand (so we're not doing it twice) return; } // Only scale avatar if both triggers and grips are squeezed var tryingToScale = this.secondarySqueezed() && this.getOtherHandController().secondarySqueezed() && this.triggerSmoothedSqueezed() && this.getOtherHandController().triggerSmoothedSqueezed(); if (!this.isScalingAvatar) { // If both secondary triggers squeezed, start scaling if (tryingToScale) { this.scalingStartDistance = Vec3.length(Vec3.subtract(this.getHandPosition(), this.getOtherHandController().getHandPosition())); this.scalingStartAvatarScale = MyAvatar.scale; this.isScalingAvatar = true; } } else if (!tryingToScale) { this.isScalingAvatar = false; } if (this.isScalingAvatar) { var scalingCurrentDistance = Vec3.length(Vec3.subtract(this.getHandPosition(), this.getOtherHandController().getHandPosition())); var newAvatarScale = (scalingCurrentDistance / this.scalingStartDistance) * this.scalingStartAvatarScale; MyAvatar.scale = newAvatarScale; } }; 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.grabbedThingID = null; this.setState(STATE_OFF, "trigger released"); return; } this.callEntityMethodOnGrabbed("continueNearTrigger"); }; this.farTrigger = function(deltaTime, timestamp) { if (this.triggerSmoothedReleased()) { this.callEntityMethodOnGrabbed("stopFarTrigger"); this.grabbedThingID = null; 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.grabbedThingID) { this.callEntityMethodOnGrabbed("stopFarTrigger"); this.grabbedThingID = null; 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 controllerLocation; if (this.useFingerInsteadOfStylus && this.state === STATE_ENTITY_STYLUS_TOUCHING) { controllerLocation = getFingerWorldLocation(this.hand); } else { controllerLocation = getControllerWorldLocation(this.handToController(), true); } var intersectInfo = handLaserIntersectEntity(this.grabbedThingID, controllerLocation); if (intersectInfo) { var pointerEvent = { type: "Press", id: this.hand + 1, // 0 is reserved for hardware mouse pos2D: projectOntoEntityXYPlane(this.grabbedThingID, intersectInfo.point), pos3D: intersectInfo.point, normal: intersectInfo.normal, direction: intersectInfo.searchRay.direction, button: "Primary", isPrimaryHeld: true }; Entities.sendMousePressOnEntity(this.grabbedThingID, pointerEvent); Entities.sendClickDownOnEntity(this.grabbedThingID, pointerEvent); this.touchingEnterTimer = 0; this.touchingEnterPointerEvent = pointerEvent; this.touchingEnterPointerEvent.button = "None"; this.deadspotExpired = false; var LASER_PRESS_TO_MOVE_DEADSPOT_ANGLE = 0.026; // radians ~ 1.2 degrees var STYLUS_PRESS_TO_MOVE_DEADSPOT_ANGLE = 0.314; // radians ~ 18 degrees var theta = this.state === STATE_ENTITY_STYLUS_TOUCHING ? STYLUS_PRESS_TO_MOVE_DEADSPOT_ANGLE : LASER_PRESS_TO_MOVE_DEADSPOT_ANGLE; this.deadspotRadius = Math.tan(theta) * intersectInfo.distance; // dead spot radius in meters } if (this.state == STATE_ENTITY_STYLUS_TOUCHING) { Controller.triggerHapticPulse(HAPTIC_STYLUS_STRENGTH, HAPTIC_STYLUS_DURATION, this.hand); } else if (this.state == STATE_ENTITY_LASER_TOUCHING) { Controller.triggerHapticPulse(HAPTIC_LASER_UI_STRENGTH, HAPTIC_LASER_UI_DURATION, this.hand); } }; this.entityTouchingExit = function() { // test for intersection between controller laser and web entity plane. var controllerLocation; if (this.useFingerInsteadOfStylus && this.state === STATE_ENTITY_STYLUS_TOUCHING) { controllerLocation = getFingerWorldLocation(this.hand); } else { controllerLocation = getControllerWorldLocation(this.handToController(), true); } var intersectInfo = handLaserIntersectEntity(this.grabbedThingID, controllerLocation); if (intersectInfo) { var pointerEvent; if (this.deadspotExpired) { pointerEvent = { type: "Release", id: this.hand + 1, // 0 is reserved for hardware mouse pos2D: projectOntoEntityXYPlane(this.grabbedThingID, 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.grabbedThingID, pointerEvent); Entities.sendClickReleaseOnEntity(this.grabbedThingID, pointerEvent); Entities.sendHoverLeaveEntity(this.grabbedThingID, pointerEvent); } this.grabbedThingID = null; this.grabbedOverlay = null; }; this.entityTouching = function(dt) { this.touchingEnterTimer += dt; entityPropertiesCache.addEntity(this.grabbedThingID); if (this.state == STATE_ENTITY_LASER_TOUCHING && !this.triggerSmoothedGrab()) { this.setState(STATE_OFF, "released trigger"); return; } // test for intersection between controller laser and web entity plane. var controllerLocation; if (this.useFingerInsteadOfStylus && this.state === STATE_ENTITY_STYLUS_TOUCHING) { controllerLocation = getFingerWorldLocation(this.hand); } else { controllerLocation = getControllerWorldLocation(this.handToController(), true); } var intersectInfo = handLaserIntersectEntity(this.grabbedThingID, controllerLocation); if (intersectInfo) { var max; if (this.useFingerInsteadOfStylus && this.state === STATE_ENTITY_STYLUS_TOUCHING) { max = FINGER_TOUCH_MAX; } else { max = WEB_STYLUS_LENGTH / 2.0 + WEB_TOUCH_Y_OFFSET; } if (this.state == STATE_ENTITY_STYLUS_TOUCHING && intersectInfo.distance > max) { this.setState(STATE_OFF, "pulled away from web entity"); return; } if (Entities.keyboardFocusEntity != this.grabbedThingID) { Overlays.keyboardFocusOverlay = 0; Entities.keyboardFocusEntity = this.grabbedThingID; } var pointerEvent = { type: "Move", id: this.hand + 1, // 0 is reserved for hardware mouse pos2D: projectOntoEntityXYPlane(this.grabbedThingID, intersectInfo.point), pos3D: intersectInfo.point, normal: intersectInfo.normal, direction: intersectInfo.searchRay.direction, button: "NoButtons", isPrimaryHeld: true }; var POINTER_PRESS_TO_MOVE_DELAY = 0.25; // seconds if (this.deadspotExpired || this.touchingEnterTimer > POINTER_PRESS_TO_MOVE_DELAY || Vec3.distance(intersectInfo.point, this.touchingEnterPointerEvent.pos3D) > this.deadspotRadius) { Entities.sendMouseMoveOnEntity(this.grabbedThingID, pointerEvent); Entities.sendHoldingClickOnEntity(this.grabbedThingID, pointerEvent); this.deadspotExpired = true; } this.intersectionDistance = intersectInfo.distance; if (this.state == STATE_ENTITY_LASER_TOUCHING) { this.searchIndicatorOn(intersectInfo.searchRay); } Reticle.setVisible(false); } else { this.grabbedThingID = null; this.setState(STATE_OFF, "grabbed entity was destroyed"); return; } }; this.overlayTouchingEnter = function () { // Test for intersection between controller laser and Web overlay plane. var controllerLocation; if (this.useFingerInsteadOfStylus && this.state === STATE_OVERLAY_STYLUS_TOUCHING) { controllerLocation = getFingerWorldLocation(this.hand); } else { controllerLocation = getControllerWorldLocation(this.handToController(), true); } var intersectInfo = handLaserIntersectOverlay(this.grabbedOverlay, controllerLocation); if (intersectInfo) { var pointerEvent = { type: "Press", id: HARDWARE_MOUSE_ID, pos2D: projectOntoOverlayXYPlane(this.grabbedOverlay, intersectInfo.point), pos3D: intersectInfo.point, normal: intersectInfo.normal, direction: intersectInfo.searchRay.direction, button: "Primary", isPrimaryHeld: true }; Overlays.sendMousePressOnOverlay(this.grabbedOverlay, pointerEvent); this.touchingEnterTimer = 0; this.touchingEnterPointerEvent = pointerEvent; this.touchingEnterPointerEvent.button = "None"; this.deadspotExpired = false; var LASER_PRESS_TO_MOVE_DEADSPOT_ANGLE = 0.026; // radians ~ 1.2 degrees var STYLUS_PRESS_TO_MOVE_DEADSPOT_ANGLE = 0.314; // radians ~ 18 degrees var theta = this.state === STATE_OVERLAY_STYLUS_TOUCHING ? STYLUS_PRESS_TO_MOVE_DEADSPOT_ANGLE : LASER_PRESS_TO_MOVE_DEADSPOT_ANGLE; this.deadspotRadius = Math.tan(theta) * intersectInfo.distance; // dead spot radius in meters } if (this.state == STATE_OVERLAY_STYLUS_TOUCHING) { Controller.triggerHapticPulse(HAPTIC_STYLUS_STRENGTH, HAPTIC_STYLUS_DURATION, this.hand); } else if (this.state == STATE_OVERLAY_LASER_TOUCHING) { Controller.triggerHapticPulse(HAPTIC_LASER_UI_STRENGTH, HAPTIC_LASER_UI_DURATION, this.hand); } }; this.overlayTouchingExit = function () { // Test for intersection between controller laser and Web overlay plane. var controllerLocation; if (this.useFingerInsteadOfStylus && this.state === STATE_OVERLAY_STYLUS_TOUCHING) { controllerLocation = getFingerWorldLocation(this.hand); } else { controllerLocation = getControllerWorldLocation(this.handToController(), true); } var intersectInfo = handLaserIntersectOverlay(this.grabbedOverlay, controllerLocation); if (intersectInfo) { var pointerEvent; var pos2D; var pos3D; if (this.tabletStabbed) { // Some people like to jam the stylus a long ways into the tablet when clicking on a button. // They almost always move out of the deadzone when they do this. We detect if the stylus // has gone far through the tablet and suppress any further faux mouse events until the // stylus is withdrawn. Once it has withdrawn, we do a release click wherever the stylus was // when it was pushed into the tablet. this.tabletStabbed = false; pos2D = this.tabletStabbedPos2D; pos3D = this.tabletStabbedPos3D; } else { pos2D = projectOntoOverlayXYPlane(this.grabbedOverlay, intersectInfo.point); pos3D = intersectInfo.point; } if (this.deadspotExpired) { pointerEvent = { type: "Release", id: HARDWARE_MOUSE_ID, pos2D: pos2D, pos3D: pos3D, normal: intersectInfo.normal, direction: intersectInfo.searchRay.direction, button: "Primary" }; } else { pointerEvent = this.touchingEnterPointerEvent; pointerEvent.type = "Release"; pointerEvent.button = "Primary"; pointerEvent.isPrimaryHeld = false; } Overlays.sendMouseReleaseOnOverlay(this.grabbedOverlay, pointerEvent); Overlays.sendHoverLeaveOverlay(this.grabbedOverlay, pointerEvent); } this.grabbedThingID = null; this.grabbedOverlay = null; }; this.overlayTouching = function (dt) { this.touchingEnterTimer += dt; if (this.state == STATE_OVERLAY_STYLUS_TOUCHING && this.triggerSmoothedSqueezed()) { return; } if (this.state == STATE_OVERLAY_LASER_TOUCHING && !this.triggerSmoothedGrab()) { this.setState(STATE_OFF, "released trigger"); return; } // Test for intersection between controller laser and Web overlay plane. var controllerLocation; if (this.useFingerInsteadOfStylus && this.state === STATE_OVERLAY_STYLUS_TOUCHING) { controllerLocation = getFingerWorldLocation(this.hand); } else { controllerLocation = getControllerWorldLocation(this.handToController(), true); } var intersectInfo = handLaserIntersectOverlay(this.grabbedOverlay, controllerLocation); if (intersectInfo) { var max, min; if (this.useFingerInsteadOfStylus && this.state === STATE_OVERLAY_STYLUS_TOUCHING) { max = FINGER_TOUCH_MAX; min = FINGER_TOUCH_MIN; } else { max = WEB_STYLUS_LENGTH / 2.0 + WEB_TOUCH_Y_OFFSET + WEB_TOUCH_Y_TOUCH_DEADZONE_SIZE; min = WEB_STYLUS_LENGTH / 2.0 + WEB_TOUCH_TOO_CLOSE; } if (this.state == STATE_OVERLAY_STYLUS_TOUCHING && intersectInfo.distance > max) { this.grabbedThingID = null; this.setState(STATE_OFF, "pulled away from overlay -- " + intersectInfo.distance + " max = " + max); return; } var pos2D = projectOntoOverlayXYPlane(this.grabbedOverlay, intersectInfo.point); var pos3D = intersectInfo.point; if (this.state == STATE_OVERLAY_STYLUS_TOUCHING && !this.tabletStabbed && intersectInfo.distance < min) { // they've stabbed the tablet, don't send events until they pull back this.tabletStabbed = true; this.tabletStabbedPos2D = pos2D; this.tabletStabbedPos3D = pos3D; return; } if (this.tabletStabbed) { var origin = {x: this.tabletStabbedPos2D.x, y: this.tabletStabbedPos2D.y, z: 0}; var point = {x: pos2D.x, y: pos2D.y, z: 0}; var offset = Vec3.distance(origin, point); var radius = 0.05; if (offset < radius) { return; } } if (Overlays.keyboardFocusOverlay != this.grabbedOverlay) { Entities.keyboardFocusEntity = null; Overlays.keyboardFocusOverlay = this.grabbedOverlay; } var pointerEvent = { type: "Move", id: HARDWARE_MOUSE_ID, pos2D: pos2D, pos3D: pos3D, normal: intersectInfo.normal, direction: intersectInfo.searchRay.direction, button: "NoButtons", isPrimaryHeld: true }; var POINTER_PRESS_TO_MOVE_DELAY = 0.25; // seconds if (this.deadspotExpired || this.touchingEnterTimer > POINTER_PRESS_TO_MOVE_DELAY || Vec3.distance(intersectInfo.point, this.touchingEnterPointerEvent.pos3D) > this.deadspotRadius) { Overlays.sendMouseMoveOnOverlay(this.grabbedOverlay, pointerEvent); this.deadspotExpired = true; } this.intersectionDistance = intersectInfo.distance; if (this.state == STATE_OVERLAY_LASER_TOUCHING) { this.searchIndicatorOn(intersectInfo.searchRay); } Reticle.setVisible(false); } else { this.grabbedThingID = null; this.setState(STATE_OFF, "grabbed overlay was destroyed"); return; } }; this.release = function() { this.turnOffVisualizations(); if (this.grabbedThingID !== null) { if (this.state === STATE_HOLD) { this.callEntityMethodOnGrabbed("releaseEquip"); } // Make a small release haptic pulse if we really were holding something Controller.triggerHapticPulse(HAPTIC_PULSE_STRENGTH, HAPTIC_PULSE_DURATION, this.hand); if (this.actionID !== null) { Entities.deleteAction(this.grabbedThingID, this.actionID); } else { // no action, so it's a parenting grab if (this.previousParentID[this.grabbedThingID] === NULL_UUID) { if (this.grabbedIsOverlay) { Overlays.editOverlay(this.grabbedThingID, { parentID: NULL_UUID, parentJointIndex: -1 }); } else { Entities.editEntity(this.grabbedThingID, { parentID: this.previousParentID[this.grabbedThingID], parentJointIndex: this.previousParentJointIndex[this.grabbedThingID] }); this.ensureDynamic(); } } else { if (this.grabbedIsOverlay) { Overlays.editOverlay(this.grabbedThingID, { parentID: this.previousParentID[this.grabbedThingID], parentJointIndex: this.previousParentJointIndex[this.grabbedThingID], }); } else { // we're putting this back as a child of some other parent, so zero its velocity Entities.editEntity(this.grabbedThingID, { parentID: this.previousParentID[this.grabbedThingID], parentJointIndex: this.previousParentJointIndex[this.grabbedThingID], velocity: {x: 0, y: 0, z: 0}, angularVelocity: {x: 0, y: 0, z: 0} }); } } } Messages.sendMessage('Hifi-Object-Manipulation', JSON.stringify({ action: 'release', grabbedEntity: this.grabbedThingID, joint: this.hand === RIGHT_HAND ? "RightHand" : "LeftHand" })); } this.actionID = null; this.grabbedThingID = null; this.grabbedOverlay = null; this.grabbedHotspot = null; if (this.triggerSmoothedGrab() || this.secondarySqueezed()) { this.waitForTriggerRelease = true; } }; this.cleanup = function() { this.release(); this.grabPointSphereOff(); }; this.thisHandIsParent = function(props) { if (props.parentID !== MyAvatar.sessionUUID && props.parentID !== AVATAR_SELF_ID) { return false; } var handJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "RightHand" : "LeftHand"); if (props.parentJointIndex == handJointIndex) { return true; } var controllerJointIndex = this.controllerJointIndex; if (props.parentJointIndex == controllerJointIndex) { return true; } var controllerCRJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "_CAMERA_RELATIVE_CONTROLLER_RIGHTHAND" : "_CAMERA_RELATIVE_CONTROLLER_LEFTHAND"); if (props.parentJointIndex == controllerCRJointIndex) { return true; } return false; }; this.checkForUnexpectedChildren = function() { var _this = this; // sometimes things can get parented to a hand and this script is unaware. Search for such entities and // unhook them. // find children of avatar's hand joint var handJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "RightHand" : "LeftHand"); var children = Entities.getChildrenIDsOfJoint(MyAvatar.sessionUUID, handJointIndex); children = children.concat(Entities.getChildrenIDsOfJoint(AVATAR_SELF_ID, handJointIndex)); // find children of faux controller joint var controllerJointIndex = this.controllerJointIndex; children = children.concat(Entities.getChildrenIDsOfJoint(MyAvatar.sessionUUID, controllerJointIndex)); children = children.concat(Entities.getChildrenIDsOfJoint(AVATAR_SELF_ID, controllerJointIndex)); // find children of faux camera-relative controller joint var controllerCRJointIndex = MyAvatar.getJointIndex(this.hand === RIGHT_HAND ? "_CAMERA_RELATIVE_CONTROLLER_RIGHTHAND" : "_CAMERA_RELATIVE_CONTROLLER_LEFTHAND"); children = children.concat(Entities.getChildrenIDsOfJoint(MyAvatar.sessionUUID, controllerCRJointIndex)); children = children.concat(Entities.getChildrenIDsOfJoint(AVATAR_SELF_ID, controllerCRJointIndex)); children.forEach(function(childID) { if (childID !== _this.stylus && childID !== _this.overlayLine) { // we appear to be holding something and this script isn't in a state that would be holding something. // unhook it. if we previously took note of this entity's parent, put it back where it was. This // works around some problems that happen when more than one hand or avatar is passing something around. if (_this.previousParentID[childID]) { var previousParentID = _this.previousParentID[childID]; var previousParentJointIndex = _this.previousParentJointIndex[childID]; // The main flaw with keeping track of previous parantage in individual scripts is: // (1) A grabs something (2) B takes it from A (3) A takes it from B (4) A releases it // now A and B will take turns passing it back to the other. Detect this and stop the loop here... var UNHOOK_LOOP_DETECT_MS = 200; var now = Date.now(); if (_this.previouslyUnhooked[childID]) { if (now - _this.previouslyUnhooked[childID] < UNHOOK_LOOP_DETECT_MS) { previousParentID = NULL_UUID; previousParentJointIndex = -1; } } _this.previouslyUnhooked[childID] = now; if (Overlays.getProperty(childID, "grabbable")) { // only auto-unhook overlays that were flagged as grabbable. this avoids unhooking overlays // used in tutorial. Overlays.editOverlay(childID, { parentID: previousParentID, parentJointIndex: previousParentJointIndex }); } Entities.editEntity(childID, { parentID: previousParentID, parentJointIndex: previousParentJointIndex }); } else { Entities.editEntity(childID, { parentID: NULL_UUID }); if (Overlays.getProperty(childID, "grabbable")) { Overlays.editOverlay(childID, { parentID: NULL_UUID }); } } } }); }; 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); function handleMenuEvent(menuItem) { if (menuItem === "Show Grab Sphere") { SHOW_GRAB_POINT_SPHERE = Menu.isOptionChecked("Show Grab Sphere"); } } Menu.addMenuItem({ menuName: "Developer", menuItemName: "Show Grab Sphere", isCheckable: true, isChecked: false }); Menu.menuItemEvent.connect(handleMenuEvent); // 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'); Messages.subscribe('Hifi-Hand-Drop'); 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; } if (data.myAvatarScalingEnabled !== undefined) { print("myAvatarScalingEnabled: ", data.myAvatarScalingEnabled); myAvatarScalingEnabled = data.myAvatarScalingEnabled; } if (data.objectScalingEnabled !== undefined) { print("objectScalingEnabled: ", data.objectScalingEnabled); objectScalingEnabled = data.objectScalingEnabled; } } 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.grabbedThingID = wearableEntity; var hotspots = selectedController.collectEquipHotspots(selectedController.grabbedThingID); 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); } } else if (channel === 'Hifi-Hand-Drop') { if (message === 'left') { leftController.release(); } else if (message === 'right') { rightController.release(); } else if (message === 'both') { leftController.release(); rightController.release(); } } } }; Messages.messageReceived.connect(handleHandMessages); var TARGET_UPDATE_HZ = 60; // 50hz good enough, but we're using update var BASIC_TIMER_INTERVAL_MS = 1000 / TARGET_UPDATE_HZ; var lastInterval = Date.now(); var intervalCount = 0; var totalDelta = 0; var totalVariance = 0; var highVarianceCount = 0; var veryhighVarianceCount = 0; var updateTotalWork = 0; var UPDATE_PERFORMANCE_DEBUGGING = false; function updateWrapper(){ intervalCount++; var thisInterval = Date.now(); var deltaTimeMsec = thisInterval - lastInterval; var deltaTime = deltaTimeMsec / 1000; lastInterval = thisInterval; totalDelta += deltaTimeMsec; var variance = Math.abs(deltaTimeMsec - BASIC_TIMER_INTERVAL_MS); totalVariance += variance; if (variance > 1) { highVarianceCount++; } if (variance > 5) { veryhighVarianceCount++; } // will call update for both hands var preWork = Date.now(); update(deltaTime); var postWork = Date.now(); var workDelta = postWork - preWork; updateTotalWork += workDelta; if (intervalCount == 100) { if (UPDATE_PERFORMANCE_DEBUGGING) { print("handControllerGrab.js -- For " + intervalCount + " samples average= " + totalDelta/intervalCount + " ms" + " average variance:" + totalVariance/intervalCount + " ms" + " high variance count:" + highVarianceCount + " [ " + (highVarianceCount/intervalCount) * 100 + "% ] " + " VERY high variance count:" + veryhighVarianceCount + " [ " + (veryhighVarianceCount/intervalCount) * 100 + "% ] " + " average work:" + updateTotalWork/intervalCount + " ms"); } intervalCount = 0; totalDelta = 0; totalVariance = 0; highVarianceCount = 0; veryhighVarianceCount = 0; updateTotalWork = 0; } } Script.update.connect(updateWrapper); function cleanup() { Menu.removeMenuItem("Developer", "Show Grab Sphere"); Script.update.disconnect(updateWrapper); rightController.cleanup(); leftController.cleanup(); Controller.disableMapping(MAPPING_NAME); Reticle.setVisible(true); } Script.scriptEnding.connect(cleanup); }()); // END LOCAL_SCOPE