diff --git a/docs/components/gltf-model.md b/docs/components/gltf-model.md index e0dc13afca0..6fb62dcfe1d 100644 --- a/docs/components/gltf-model.md +++ b/docs/components/gltf-model.md @@ -83,6 +83,7 @@ file. | Event Name | Description | |--------------|--------------------------------------------| | model-loaded | glTF model has been loaded into the scene. | +| model-error | glTF model could not be loaded. | ## Loading Inline diff --git a/docs/components/laser-controls.md b/docs/components/laser-controls.md index 095f3024053..9a5e119dafd 100644 --- a/docs/components/laser-controls.md +++ b/docs/components/laser-controls.md @@ -24,6 +24,7 @@ across all VR platforms with a single line of HTML. [oculus-touch-controls]: ./oculus-touch-controls.md [tracked-controls]: ./tracked-controls.md [vive-controls]: ./vive-controls.md +[windows-motion-controls]: ./windows-motion-controls.md laser-controls is a **higher-order component**, meaning the component wraps and configures other components, rather than implementing any logic itself. Under @@ -33,6 +34,7 @@ the hood, laser-controls sets all of the tracked controller components: - [oculus-touch-controls] - [daydream-controls] - [gearvr-controls] +- [windows-motion-controls] [cursor]: ./cursor.md [raycaster]: ./raycaster.md diff --git a/docs/components/tracked-controls.md b/docs/components/tracked-controls.md index c391fb1c5ce..30ef76fc6c8 100644 --- a/docs/components/tracked-controls.md +++ b/docs/components/tracked-controls.md @@ -11,12 +11,13 @@ examples: [] [oculustouchcontrols]: ./oculus-touch-controls.md [vivecontrols]: ./vive-controls.md [daydreamcontrols]: ./daydream-controls.md +[windowsmotioncontrols]: ./windows-motion-controls.md The tracked-controls component interfaces with tracked controllers. tracked-controls uses the Gamepad API to handle tracked controllers, and is abstracted by the [hand-controls component][handcontrols] as well as the -[vive-controls][vivecontrols], [oculus-touch-controls][oculustouchcontrols], and -[daydream-controls][daydreamcontrols] +[vive-controls][vivecontrols], [oculus-touch-controls][oculustouchcontrols], +[windows-motion-controls][windowsmotioncontrols], and [daydream-controls][daydreamcontrols] components. This component elects the appropriate controller, applies pose to the entity, observes buttons state and emits appropriate events. For non-6DOF controllers such as [daydream-controls][daydreamcontrols], a primitive arm model is used to emulate diff --git a/docs/components/windows-motion-controls.md b/docs/components/windows-motion-controls.md new file mode 100644 index 00000000000..a7995d19d0b --- /dev/null +++ b/docs/components/windows-motion-controls.md @@ -0,0 +1,60 @@ +--- +title: windows-motion-controls +type: components +layout: docs +parent_section: components +source_code: src/components/windows-motion-controls.js +examples: [] +--- + +[trackedcontrols]: ./tracked-controls.md + +The windows-motion-controls component interfaces with any spatial controllers exposed through +Windows Mixed Reality as Spatial Input Sources (such as Motion Controllers). +It wraps the [tracked-controls component][trackedcontrols] while adding button +mappings, events, and a controller model that highlights applies position/rotation transforms +to the pressed buttons (trigger, grip, menu, thumbstick, trackpad) and moved axes (thumbstick and trackpad.) + +## Example + +```html + + +``` + +## Value + +| Property | Description | Default Value | +|----------------------|---------------------------------------------------------------------------------------------------|----------------| +| hand | The hand that will be tracked (i.e., right, left). | right | +| pair | Which pair of controllers, if > 2 are connected. | 0 | +| model | Whether the controller model is loaded. | true | +| hideDisconnected | Disable rendering of controller model when no matching gamepad (based on ID & hand) is connected. | true | + + +## Events + +| Event Name | Description | +| ---------- | ----------- | +| thumbstickdown | Thumbstick button pressed. | +| thumbstickup | Thumbstick button released. | +| thumbstickchanged | Thumbstick button changed. | +| thumbstickmoved | Thumbstick axis moved. | +| triggerdown | Trigger pressed. | +| triggerup | Trigger released. | +| triggerchanged | Trigger changed. | +| gripdown | Grip button pressed. | +| gripup | Grip button released. | +| gripchanged | Grip button changed. | +| menudown | Menu button pressed. | +| menuup | Menu button released. | +| menuchanged | Menu button changed. | +| trackpaddown | Trackpad pressed. | +| trackpadup | Trackpad released. | +| trackpadchanged | Trackpad button changed. | +| trackpadmoved | Trackpad axis moved. | +| controllermodelready | The model file is loaded and completed parsing. | + +## Assets + +TBC. diff --git a/docs/introduction/interactions-and-controllers.md b/docs/introduction/interactions-and-controllers.md index 4c29307ce60..8a854cecaa4 100644 --- a/docs/introduction/interactions-and-controllers.md +++ b/docs/introduction/interactions-and-controllers.md @@ -464,6 +464,7 @@ AFRAME.registerComponent('custom-controls', { el.setAttribute('oculus-touch-controls', controlConfiguration); el.setAttribute('daydream-controls', controlConfiguration); el.setAttribute('gearvr-controls', controlConfiguration); + el.setAttribute('windows-motion-controls', controlConfiguration); // Set a model. el.setAttribute('gltf-model', this.data.model); @@ -492,6 +493,7 @@ event handlers how we want: - [hand-controls events](../components/hand-controls.md#events) - [oculus-touch-controls events](../components/oculus-touch-controls.md#events) - [vive-controls events](../components/vive-controls.md#events) +- [windows-motion-controls events](../components/windows-motion-controls.md#events) For example, we can listen to the Oculus Touch X button press, and toggle visibility of an entity. In component form: diff --git a/src/components/gltf-model.js b/src/components/gltf-model.js index 79147c72e31..cbec4926f90 100644 --- a/src/components/gltf-model.js +++ b/src/components/gltf-model.js @@ -1,5 +1,7 @@ var registerComponent = require('../core/component').registerComponent; var THREE = require('../lib/three'); +var utils = require('../utils/'); +var warn = utils.debug('components:gltf-model:warn'); /** * glTF model loader. @@ -26,6 +28,10 @@ module.exports.Component = registerComponent('gltf-model', { self.model.animations = gltfModel.animations; el.setObject3D('mesh', self.model); el.emit('model-loaded', {format: 'gltf', model: self.model}); + }, undefined /* onProgress */, function gltfFailed (error) { + var message = (error && error.message) ? error.message : 'Failed to load glTF model'; + warn(message); + el.emit('model-error', {format: 'gltf', src: src}); }); }, diff --git a/src/components/hand-controls.js b/src/components/hand-controls.js index fb6e3ccb2c7..6cd983fa978 100644 --- a/src/components/hand-controls.js +++ b/src/components/hand-controls.js @@ -30,7 +30,9 @@ EVENTS[ANIMATIONS.point] = 'pointing'; EVENTS[ANIMATIONS.thumb] = 'thumb'; /** - * Hand controls component that abstracts 6DoF controls: oculus-touch-controls, vive-controls. + * Hand controls component that abstracts 6DoF controls: + * oculus-touch-controls, vive-controls, windows-motion-controls. + * * Originally meant to be a sample implementation of applications-specific controls that * abstracts multiple types of controllers. * @@ -49,7 +51,7 @@ module.exports.Component = registerComponent('hand-controls', { var self = this; // Current pose. this.gesture = ANIMATIONS.open; - // Active buttons populated by events provided by oculus-touch-controls and vive-controls. + // Active buttons populated by events provided by the attached controls. this.pressedButtons = {}; this.touchedButtons = {}; this.loader = new THREE.ObjectLoader(); @@ -158,7 +160,7 @@ module.exports.Component = registerComponent('hand-controls', { var el = this.el; var hand = this.data; - // Get common configuration to abstract Vive and Oculus. + // Get common configuration to abstract different vendor controls. controlConfiguration = { hand: hand, model: false, @@ -166,6 +168,7 @@ module.exports.Component = registerComponent('hand-controls', { }; el.setAttribute('vive-controls', controlConfiguration); el.setAttribute('oculus-touch-controls', controlConfiguration); + el.setAttribute('windows-motion-controls', controlConfiguration); // Set model. if (hand !== previousHand) { @@ -233,22 +236,22 @@ module.exports.Component = registerComponent('hand-controls', { var isTrackpadActive = this.pressedButtons['trackpad'] || this.touchedButtons['trackpad']; var isTriggerActive = this.pressedButtons['trigger'] || this.touchedButtons['trigger']; var isABXYActive = this.touchedButtons['AorX'] || this.touchedButtons['BorY']; - var isOculusTouch = isOculusTouchController(this.el.components['tracked-controls']); + var isVive = isViveController(this.el.components['tracked-controls']); - // Works well with Oculus Touch but Vive needs tweaks. + // Works well with Oculus Touch and Windows Motion Controls, but Vive needs tweaks. if (isGripActive) { - if (!isOculusTouch) { + if (isVive) { gesture = ANIMATIONS.fist; } else - if (isSurfaceActive || isABXYActive || isTrackpadActive) { - gesture = isTriggerActive ? ANIMATIONS.fist : ANIMATIONS.point; - } else { - gesture = isTriggerActive ? ANIMATIONS.thumbUp : ANIMATIONS.pointThumb; - } + if (isSurfaceActive || isABXYActive || isTrackpadActive) { + gesture = isTriggerActive ? ANIMATIONS.fist : ANIMATIONS.point; + } else { + gesture = isTriggerActive ? ANIMATIONS.thumbUp : ANIMATIONS.pointThumb; + } } else { if (isTriggerActive) { - gesture = isOculusTouch ? ANIMATIONS.hold : ANIMATIONS.fist; - } else if (!isOculusTouch && isTrackpadActive) { + gesture = !isVive ? ANIMATIONS.hold : ANIMATIONS.fist; + } else if (isVive && isTrackpadActive) { gesture = ANIMATIONS.point; } } @@ -355,8 +358,8 @@ function getGestureEventName (gesture, active) { return; } -function isOculusTouchController (trackedControls) { +function isViveController (trackedControls) { var controllerId = trackedControls && trackedControls.controller && trackedControls.controller.id; - return controllerId && controllerId.indexOf('Oculus Touch') === 0; + return controllerId && controllerId.indexOf('OpenVR ') === 0; } diff --git a/src/components/index.js b/src/components/index.js index 00119985529..190b6171097 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -25,6 +25,7 @@ require('./tracked-controls'); require('./visible'); require('./vive-controls'); require('./wasd-controls'); +require('./windows-motion-controls'); require('./scene/debug'); require('./scene/embedded'); diff --git a/src/components/laser-controls.js b/src/components/laser-controls.js index 04a2ca30cae..e75e09c2214 100644 --- a/src/components/laser-controls.js +++ b/src/components/laser-controls.js @@ -16,21 +16,41 @@ registerComponent('laser-controls', { el.setAttribute('gearvr-controls', {hand: data.hand}); el.setAttribute('oculus-touch-controls', {hand: data.hand}); el.setAttribute('vive-controls', {hand: data.hand}); + el.setAttribute('windows-motion-controls', {hand: data.hand}); - // Wait for controller to connect before - el.addEventListener('controllerconnected', function (evt) { + // Wait for controller to connect, or have a valid pointing pose, before creating ray + el.addEventListener('controllerconnected', createRay); + el.addEventListener('controllermodelready', createRay); + + function createRay (evt) { var controllerConfig = config[evt.detail.name]; if (!controllerConfig) { return; } - el.setAttribute('raycaster', utils.extend({ + // Show the line unless a particular config opts to hide it, until a controllermodelready + // event comes through. + var raycasterConfig = utils.extend({ showLine: true - }, controllerConfig.raycaster || {})); + }, controllerConfig.raycaster || {}); + + // The controllermodelready event contains a rayOrigin that takes into account + // offsets specific to the loaded model. + if (evt.detail.rayOrigin) { + raycasterConfig.origin = evt.detail.rayOrigin.origin; + raycasterConfig.direction = evt.detail.rayOrigin.direction; + raycasterConfig.showLine = true; + } + + // Only apply a default raycaster if it does not yet exist. This prevents it overwriting + // config applied from a controllermodelready event. + if (evt.detail.rayOrigin || !el.hasAttribute('raycaster')) { + el.setAttribute('raycaster', raycasterConfig); + } el.setAttribute('cursor', utils.extend({ fuse: false }, controllerConfig.cursor)); - }); + } }, config: { @@ -50,6 +70,11 @@ registerComponent('laser-controls', { 'vive-controls': { cursor: {downEvents: ['triggerdown'], upEvents: ['triggerup']} + }, + + 'windows-motion-controls': { + cursor: {downEvents: ['triggerdown'], upEvents: ['triggerup']}, + raycaster: {showLine: false} } } }); diff --git a/src/components/raycaster.js b/src/components/raycaster.js index c6ced672f46..eafe0537b12 100644 --- a/src/components/raycaster.js +++ b/src/components/raycaster.js @@ -62,7 +62,7 @@ module.exports.Component = registerComponent('raycaster', { // Draw line. if (data.showLine && (data.far !== oldData.far || data.origin !== oldData.origin || - data.direction !== oldData.direction)) { + data.direction !== oldData.direction || data.showLine !== oldData.showLine)) { this.unitLineEndVec3.copy(data.origin).add(data.direction).normalize(); this.drawLine(); } diff --git a/src/components/tracked-controls.js b/src/components/tracked-controls.js index 5bd45016d68..dad5395d1a4 100644 --- a/src/components/tracked-controls.js +++ b/src/components/tracked-controls.js @@ -1,4 +1,5 @@ var registerComponent = require('../core/component').registerComponent; +var controllerUtils = require('../utils/tracked-controls'); var THREE = require('../lib/three'); var DEFAULT_CAMERA_HEIGHT = require('../constants').DEFAULT_CAMERA_HEIGHT; @@ -14,13 +15,15 @@ var FOREARM = {x: 0, y: 0, z: -0.175}; * Select the appropriate controller and apply pose to the entity. * Observe button states and emit appropriate events. * - * @property {number} controller - Index of controller in array returned by Gamepad API. + * @property {number} controller - Index of controller in array returned by Gamepad API. Only used if hand property is not set. * @property {string} id - Selected controller among those returned by Gamepad API. + * @property {number} hand - If multiple controllers found with id, choose the one with the given value for hand. If set, we ignore 'controller' property */ module.exports.Component = registerComponent('tracked-controls', { schema: { controller: {default: 0}, id: {type: 'string', default: ''}, + hand: {type: 'string', default: ''}, idPrefix: {type: 'string', default: ''}, rotationOffset: {default: 0}, // Arm model parameters when not 6DoF. @@ -31,6 +34,7 @@ module.exports.Component = registerComponent('tracked-controls', { init: function () { this.axis = [0, 0, 0]; this.buttonStates = {}; + this.targetControllerNumber = this.data.controller; this.dolly = new THREE.Object3D(); this.controllerEuler = new THREE.Euler(); @@ -70,25 +74,20 @@ module.exports.Component = registerComponent('tracked-controls', { }, /** - * Handle update to `id` or `idPrefix. + * Handle update controller match criteria (such as `id`, `idPrefix`, `hand`, `controller`) */ updateGamepad: function () { - var controllers = this.system.controllers; var data = this.data; - var i; - var matchCount = 0; - - // Hand IDs: 0 is right, 1 is left. - for (i = 0; i < controllers.length; i++) { - if ((data.idPrefix && controllers[i].id.indexOf(data.idPrefix) === 0) || - (!data.idPrefix && controllers[i].id === data.id)) { - matchCount++; - if (matchCount - 1 === data.controller) { - this.controller = controllers[i]; - return; - } - } - } + var controller = controllerUtils.findMatchingController( + this.system.controllers, + data.id, + data.idPrefix, + data.hand, + data.controller + ); + + // Only replace the stored controller if we find a new one. + this.controller = controller || this.controller; }, applyArmModel: function (controllerPosition) { diff --git a/src/components/windows-motion-controls.js b/src/components/windows-motion-controls.js new file mode 100644 index 00000000000..c16f10ae7c7 --- /dev/null +++ b/src/components/windows-motion-controls.js @@ -0,0 +1,445 @@ +/* global THREE */ +var bind = require('../utils/bind'); +var registerComponent = require('../core/component').registerComponent; +var controllerUtils = require('../utils/tracked-controls'); +var utils = require('../utils/'); + +var debug = utils.debug('components:windows-motion-controls:debug'); +var warn = utils.debug('components:windows-motion-controls:warn'); + +var DEFAULT_HANDEDNESS = require('../constants').DEFAULT_HANDEDNESS; + +var MODEL_BASE_URL = 'https://cdn.aframe.io/controllers/microsoft/'; +var MODEL_FILENAMES = { left: 'left.glb', right: 'right.glb', default: 'universal.glb' }; + +var GAMEPAD_ID_PREFIX = 'Spatial Controller (Spatial Interaction Source) '; +var GAMEPAD_ID_PATTERN = /([0-9a-zA-Z]+-[0-9a-zA-Z]+)$/; + +/** + * Windows Motion Controller Controls Component + * Interfaces with Windows Motion Controller controllers and maps Gamepad events to + * common controller buttons: trackpad, trigger, grip, menu and system + * It loads a controller model and transforms the pressed buttons + */ +module.exports.Component = registerComponent('windows-motion-controls', { + schema: { + hand: {default: DEFAULT_HANDEDNESS}, + // It is possible to have multiple pairs of controllers attached (a pair has both left and right). + // Set this to 1 to use a controller from the second pair, 2 from the third pair, etc. + pair: {default: 0}, + // If true, loads the controller glTF asset. + model: {default: true}, + // If true, will hide the model from the scene if no matching gamepad (based on ID & hand) is connected. + hideDisconnected: {default: true} + }, + + mapping: { + // A-Frame specific semantic axis names + axes: {'thumbstick': [0, 1], 'trackpad': [2, 3]}, + // A-Frame specific semantic button names + buttons: ['thumbstick', 'trigger', 'grip', 'menu', 'trackpad'], + // A mapping of the semantic name to node name in the glTF model file, + // that should be transformed by axis value. + // This array mirrors the browser Gamepad.axes array, such that + // the mesh corresponding to axis 0 is in this array index 0. + axisMeshNames: [ + 'THUMBSTICK_X', + 'THUMBSTICK_Y', + 'TOUCHPAD_TOUCH_X', + 'TOUCHPAD_TOUCH_Y' + ], + // A mapping of the semantic name to button node name in the glTF model file, + // that should be transformed by button value. + buttonMeshNames: { + 'trigger': 'SELECT', + 'menu': 'MENU', + 'grip': 'GRASP', + 'thumbstick': 'THUMBSTICK_PRESS', + 'trackpad': 'TOUCHPAD_PRESS' + }, + pointingPoseMeshName: 'Pointing_Pose', + holdingPoseMeshName: 'Holding_Pose' + }, + + bindMethods: function () { + this.onModelError = bind(this.onModelError, this); + this.onModelLoaded = bind(this.onModelLoaded, this); + this.onControllersUpdate = bind(this.onControllersUpdate, this); + this.checkIfControllerPresent = bind(this.checkIfControllerPresent, this); + this.onAxisMoved = bind(this.onAxisMoved, this); + }, + + init: function () { + var self = this; + this.onButtonChanged = bind(this.onButtonChanged, this); + this.onButtonDown = function (evt) { self.onButtonEvent(evt, 'down'); }; + this.onButtonUp = function (evt) { self.onButtonEvent(evt, 'up'); }; + this.onButtonTouchStart = function (evt) { self.onButtonEvent(evt, 'touchstart'); }; + this.onButtonTouchEnd = function (evt) { self.onButtonEvent(evt, 'touchend'); }; + this.controllerPresent = false; + this.lastControllerCheck = 0; + this.previousButtonValues = {}; + this.bindMethods(); + + // Cache for submeshes that we have looked up by name. + this.loadedMeshInfo = { + buttonMeshes: null, + axisMeshes: null + }; + + // Pointing poses + this.rayOrigin = { + origin: new THREE.Vector3(), + direction: new THREE.Vector3(0, 0, -1) + }; + + // Stored on object to allow for mocking in tests + this.emitIfAxesChanged = controllerUtils.emitIfAxesChanged; + this.checkControllerPresentAndSetup = controllerUtils.checkControllerPresentAndSetup; + }, + + addEventListeners: function () { + var el = this.el; + el.addEventListener('buttonchanged', this.onButtonChanged); + el.addEventListener('buttondown', this.onButtonDown); + el.addEventListener('buttonup', this.onButtonUp); + el.addEventListener('touchstart', this.onButtonTouchStart); + el.addEventListener('touchend', this.onButtonTouchEnd); + el.addEventListener('axismove', this.onAxisMoved); + el.addEventListener('model-error', this.onModelError); + el.addEventListener('model-loaded', this.onModelLoaded); + this.controllerEventsActive = true; + }, + + removeEventListeners: function () { + var el = this.el; + el.removeEventListener('buttonchanged', this.onButtonChanged); + el.removeEventListener('buttondown', this.onButtonDown); + el.removeEventListener('buttonup', this.onButtonUp); + el.removeEventListener('touchstart', this.onButtonTouchStart); + el.removeEventListener('touchend', this.onButtonTouchEnd); + el.removeEventListener('axismove', this.onAxisMoved); + el.removeEventListener('model-error', this.onModelError); + el.removeEventListener('model-loaded', this.onModelLoaded); + this.controllerEventsActive = false; + }, + + checkIfControllerPresent: function () { + this.checkControllerPresentAndSetup(this, GAMEPAD_ID_PREFIX, { + hand: this.data.hand, + index: this.data.pair + }); + + if (this.data.hideDisconnected) { + this.el.setAttribute('visible', this.controllerPresent); + } + }, + + play: function () { + this.checkIfControllerPresent(); + this.addControllersUpdateListener(); + + window.addEventListener('gamepadconnected', this.checkIfControllerPresent, false); + window.addEventListener('gamepaddisconnected', this.checkIfControllerPresent, false); + }, + + pause: function () { + this.removeEventListeners(); + this.removeControllersUpdateListener(); + + window.removeEventListener('gamepadconnected', this.checkIfControllerPresent, false); + window.removeEventListener('gamepaddisconnected', this.checkIfControllerPresent, false); + }, + + updateControllerModel: function () { + // If we do not want to load a model, or, have already loaded the model, emit the controllermodelready event. + if (!this.data.model || this.el.getAttribute('gltf-model')) { + this.modelReady(); + return; + } + + var sourceUrl = this.createControllerModelUrl(); + this.loadModel(sourceUrl); + }, + + /** + * Helper function that constructs a URL from the controller ID suffix, for future proofed + * art assets. + */ + createControllerModelUrl: function (forceDefault) { + // Determine the device specific folder based on the ID suffix + var trackedControlsComponent = this.el.components['tracked-controls']; + var controller = trackedControlsComponent ? trackedControlsComponent.controller : null; + var device = 'default'; + var hand = this.data.hand; + var filename; + + if (controller) { + // Read hand directly from the controller, rather than this.data, as in the case that the controller + // is unhanded this.data will still have 'left' or 'right' (depending on what the user inserted in to the scene). + // In this case, we want to load the universal model, so need to get the '' from the controller. + hand = controller.hand; + + if (!forceDefault) { + var match = controller.id.match(GAMEPAD_ID_PATTERN); + device = ((match && match[0]) || device); + } + } + + // Hand + filename = MODEL_FILENAMES[hand] || MODEL_FILENAMES.default; + + // Final url + return MODEL_BASE_URL + device + '/' + filename; + }, + + injectTrackedControls: function () { + var data = this.data; + this.el.setAttribute('tracked-controls', { + idPrefix: GAMEPAD_ID_PREFIX, + controller: data.pair, + hand: data.hand + }); + + this.updateControllerModel(); + }, + + addControllersUpdateListener: function () { + this.el.sceneEl.addEventListener('controllersupdated', this.onControllersUpdate, false); + }, + + removeControllersUpdateListener: function () { + this.el.sceneEl.removeEventListener('controllersupdated', this.onControllersUpdate, false); + }, + + onControllersUpdate: function () { + this.checkIfControllerPresent(); + }, + + onModelError: function (evt) { + var defaultUrl = this.createControllerModelUrl(true); + if (evt.detail.src !== defaultUrl) { + warn('Failed to load controller model for device, attempting to load default.'); + this.loadModel(defaultUrl); + } else { + warn('Failed to load default controller model.'); + } + }, + + loadModel: function (url) { + debug('Loading asset from: ' + url); + + // The model is loaded by the gltf-model compoent when this attribute is initially set, + // removed and re-loaded if the given url changes. + this.el.setAttribute('gltf-model', 'url(' + url + ')'); + }, + + onModelLoaded: function (evt) { + var controllerObject3D = evt.detail.model; + var loadedMeshInfo = this.loadedMeshInfo; + var i; + var meshName; + var mesh; + var meshInfo; + var quaternion = new THREE.Quaternion(); + + debug('Processing model'); + + // Find the appropriate nodes + var rootNode = controllerObject3D.getObjectByName('RootNode'); + rootNode.updateMatrixWorld(); + + // Reset the caches + loadedMeshInfo.buttonMeshes = {}; + loadedMeshInfo.axisMeshes = {}; + + // Cache our meshes so we aren't traversing the hierarchy per frame + if (rootNode) { + // Button Meshes + for (i = 0; i < this.mapping.buttons.length; i++) { + meshName = this.mapping.buttonMeshNames[this.mapping.buttons[i]]; + if (!meshName) { + debug('Skipping unknown button at index: ' + i + ' with mapped name: ' + this.mapping.buttons[i]); + continue; + } + + mesh = rootNode.getObjectByName(meshName); + if (!mesh) { + warn('Missing button mesh with name: ' + meshName); + continue; + } + + meshInfo = { + index: i, + value: getImmediateChildByName(mesh, 'VALUE'), + pressed: getImmediateChildByName(mesh, 'PRESSED'), + unpressed: getImmediateChildByName(mesh, 'UNPRESSED') + }; + if (meshInfo.value && meshInfo.pressed && meshInfo.unpressed) { + loadedMeshInfo.buttonMeshes[this.mapping.buttons[i]] = meshInfo; + } else { + // If we didn't find the mesh, it simply means this button won't have transforms applied as mapped button value changes. + warn('Missing button submesh under mesh with name: ' + meshName + + '(VALUE: ' + !!meshInfo.value + + ', PRESSED: ' + !!meshInfo.pressed + + ', UNPRESSED:' + !!meshInfo.unpressed + + ')'); + } + } + + // Axis Meshes + for (i = 0; i < this.mapping.axisMeshNames.length; i++) { + meshName = this.mapping.axisMeshNames[i]; + if (!meshName) { + debug('Skipping unknown axis at index: ' + i); + continue; + } + + mesh = rootNode.getObjectByName(meshName); + if (!mesh) { + warn('Missing axis mesh with name: ' + meshName); + continue; + } + + meshInfo = { + index: i, + value: getImmediateChildByName(mesh, 'VALUE'), + min: getImmediateChildByName(mesh, 'MIN'), + max: getImmediateChildByName(mesh, 'MAX') + }; + if (meshInfo.value && meshInfo.min && meshInfo.max) { + loadedMeshInfo.axisMeshes[i] = meshInfo; + } else { + // If we didn't find the mesh, it simply means this axis won't have transforms applied as mapped axis values change. + warn('Missing axis submesh under mesh with name: ' + meshName + + '(VALUE: ' + !!meshInfo.value + + ', MIN: ' + !!meshInfo.min + + ', MAX:' + !!meshInfo.max + + ')'); + } + } + + // Calculate the pointer pose (used for rays), by applying the inverse holding pose, then the pointing pose. + this.rayOrigin.origin.set(0, 0, 0); + this.rayOrigin.direction.set(0, 0, -1); + + // Holding pose + mesh = rootNode.getObjectByName(this.mapping.holdingPoseMeshName); + if (mesh) { + mesh.localToWorld(this.rayOrigin.origin); + this.rayOrigin.direction.applyQuaternion(quaternion.setFromEuler(mesh.rotation).inverse()); + } else { + debug('Mesh does not contain holding origin data.'); + document.getElementById('debug').innerHTML += '
Mesh does not contain holding origin data.'; + } + + // Pointing pose + mesh = rootNode.getObjectByName(this.mapping.pointingPoseMeshName); + if (mesh) { + var offset = new THREE.Vector3(); + mesh.localToWorld(offset); + this.rayOrigin.origin.add(offset); + + this.rayOrigin.direction.applyQuaternion(quaternion.setFromEuler(mesh.rotation)); + } else { + debug('Mesh does not contain pointing origin data, defaulting to none.'); + } + + // Emit event stating that our pointing ray is now accurate. + this.modelReady(); + } else { + warn('No node with name "RootNode" in controller glTF.'); + } + + debug('Model load complete.'); + + // Look through only immediate children. This will return null if no mesh exists with the given name. + function getImmediateChildByName (object3d, value) { + for (var i = 0, l = object3d.children.length; i < l; i++) { + var obj = object3d.children[i]; + if (obj && obj['name'] === value) { + return obj; + } + } + return undefined; + } + }, + + lerpAxisTransform: (function () { + var quaternion = new THREE.Quaternion(); + return function (axis, axisValue) { + var axisMeshInfo = this.loadedMeshInfo.axisMeshes[axis]; + if (!axisMeshInfo) return; + + var min = axisMeshInfo.min; + var max = axisMeshInfo.max; + var target = axisMeshInfo.value; + + // Convert from gamepad value range (-1 to +1) to lerp range (0 to 1) + var lerpValue = axisValue * 0.5 + 0.5; + target.setRotationFromQuaternion(quaternion.copy(min.quaternion).slerp(max.quaternion, lerpValue)); + target.position.lerpVectors(min.position, max.position, lerpValue); + }; + })(), + + lerpButtonTransform: (function () { + var quaternion = new THREE.Quaternion(); + return function (buttonName, buttonValue) { + var buttonMeshInfo = this.loadedMeshInfo.buttonMeshes[buttonName]; + if (!buttonMeshInfo) return; + + var min = buttonMeshInfo.unpressed; + var max = buttonMeshInfo.pressed; + var target = buttonMeshInfo.value; + + target.setRotationFromQuaternion(quaternion.copy(min.quaternion).slerp(max.quaternion, buttonValue)); + target.position.lerpVectors(min.position, max.position, buttonValue); + }; + })(), + + modelReady: function () { + this.el.emit('controllermodelready', { + name: 'windows-motion-controls', + model: this.data.model, + rayOrigin: this.rayOrigin + }); + }, + + onButtonChanged: function (evt) { + var buttonName = this.mapping.buttons[evt.detail.id]; + + if (buttonName) { + // Update the button mesh transform + if (this.loadedMeshInfo && this.loadedMeshInfo.buttonMeshes) { + this.lerpButtonTransform(buttonName, evt.detail.state.value); + } + + // Only emit events for buttons that we know how to map from index to name + this.el.emit(buttonName + 'changed', evt.detail.state); + } + }, + + onButtonEvent: function (evt, evtName) { + var buttonName = this.mapping.buttons[evt.detail.id]; + debug('onButtonEvent(' + evt.detail.id + ', ' + evtName + ')'); + + if (buttonName) { + // Only emit events for buttons that we know how to map from index to name + this.el.emit(buttonName + evtName); + } + }, + + onAxisMoved: function (evt) { + var numAxes = this.mapping.axisMeshNames.length; + + // Only attempt to update meshes if we have valid data. + if (this.loadedMeshInfo && this.loadedMeshInfo.axisMeshes) { + for (var axis = 0; axis < numAxes; axis++) { + // Update the button mesh transform + this.lerpAxisTransform(axis, evt.detail.axis[axis] || 0.0); + } + } + + this.emitIfAxesChanged(this, this.mapping.axes, evt); + } +}); diff --git a/src/utils/tracked-controls.js b/src/utils/tracked-controls.js index fe777f24e2b..19b808f74d3 100644 --- a/src/utils/tracked-controls.js +++ b/src/utils/tracked-controls.js @@ -1,5 +1,6 @@ var DEFAULT_HANDEDNESS = require('../constants').DEFAULT_HANDEDNESS; var AXIS_LABELS = ['x', 'y', 'z', 'w']; +var NUM_HANDS = 2; // Number of hands in a pair. Should always be 2. /** * Called on controller component `.play` handlers. @@ -46,14 +47,12 @@ module.exports.checkControllerPresentAndSetup = function (component, idPrefix, q * @param {object} queryObject - Map of values to match. */ function isControllerPresent (component, idPrefix, queryObject) { - var gamepad; var gamepads; - var i; - var index = 0; - var isPrefixMatch; - var isPresent = false; var sceneEl = component.el.sceneEl; var trackedControlsSystem; + var filterControllerIndex = queryObject.index || 0; + + if (!idPrefix) { return false; } trackedControlsSystem = sceneEl && sceneEl.systems['tracked-controls']; if (!trackedControlsSystem) { return false; } @@ -61,26 +60,61 @@ function isControllerPresent (component, idPrefix, queryObject) { gamepads = trackedControlsSystem.controllers; if (!gamepads.length) { return false; } - for (i = 0; i < gamepads.length; ++i) { - gamepad = gamepads[i]; - isPrefixMatch = (!idPrefix || idPrefix === '' || gamepad.id.indexOf(idPrefix) === 0); - isPresent = isPrefixMatch; - if (isPresent && queryObject.hand) { - isPresent = (gamepad.hand || DEFAULT_HANDEDNESS) === queryObject.hand; + return !!findMatchingController(gamepads, null, idPrefix, queryObject.hand, filterControllerIndex); +} + +module.exports.isControllerPresent = isControllerPresent; + +/** + * Walk through the given controllers to find any where the device ID equals filterIdExact, or startWith filterIdPrefix. + * A controller where this considered true is considered a 'match'. + * + * For each matching controller: + * If filterHand is set, and the controller: + * is handed, we further verify that controller.hand equals filterHand. + * is unhanded (controller.hand is ''), we skip until we have found a number of matching controllers that equals filterControllerIndex + * If filterHand is not set, we skip until we have found the nth matching controller, where n equals filterControllerIndex + * + * The method should be called with one of: [filterIdExact, filterIdPrefix] AND one or both of: [filterHand, filterControllerIndex] + * + * @param {object} controllers - Array of gamepads to search + * @param {string} filterIdExact - If set, used to find controllers with id === this value + * @param {string} filterIdPrefix - If set, used to find controllers with id startsWith this value + * @param {object} filterHand - If set, further filters controllers with matching 'hand' property + * @param {object} filterControllerIndex - Find the nth matching controller, where n equals filterControllerIndex. defaults to 0. + */ +function findMatchingController (controllers, filterIdExact, filterIdPrefix, filterHand, filterControllerIndex) { + var controller; + var i; + var matchingControllerOccurence = 0; + var targetControllerMatch = filterControllerIndex || 0; + + for (i = 0; i < controllers.length; i++) { + controller = controllers[i]; + // Determine if the controller ID matches our criteria + if (filterIdPrefix && controller.id.indexOf(filterIdPrefix) === -1) { continue; } + if (!filterIdPrefix && controller.id !== filterIdExact) { continue; } + + // If the hand filter and controller handedness are defined we compare them. + if (filterHand && controller.hand && filterHand !== controller.hand) { continue; } + + // If we have detected an unhanded controller and the component was asking for a particular hand, + // we need to treat the controllers in the array as pairs of controllers. This effectively means that we + // need to skip NUM_HANDS matches for each controller number, instead of 1. + if (filterHand && !controller.hand) { + targetControllerMatch = NUM_HANDS * filterControllerIndex + ((filterHand === DEFAULT_HANDEDNESS) ? 0 : 1); } - if (isPresent && queryObject.index) { - // Need to use count of gamepads with idPrefix. - isPresent = index === queryObject.index; + + // We are looking for the nth occurence of a matching controller (n equals targetControllerMatch). + if (matchingControllerOccurence === targetControllerMatch) { + return controller; } - if (isPresent) { break; } - // Update count of gamepads with idPrefix. - if (isPrefixMatch) { index++; } + ++matchingControllerOccurence; } - - return isPresent; + return undefined; } -module.exports.isControllerPresent = isControllerPresent; +module.exports.findMatchingController = findMatchingController; /** * Emit specific `moved` event(s) if axes changed based on original axismoved event. @@ -103,7 +137,7 @@ module.exports.emitIfAxesChanged = function (component, axesMapping, evt) { changed = false; for (j = 0; j < axes.length; j++) { - if (evt.detail.changed[j]) { changed = true; } + if (evt.detail.changed[axes[j]]) { changed = true; } } if (!changed) { continue; } @@ -111,7 +145,7 @@ module.exports.emitIfAxesChanged = function (component, axesMapping, evt) { // Axis has changed. Emit the specific moved event with axis values in detail. detail = {}; for (j = 0; j < axes.length; j++) { - detail[AXIS_LABELS[axes[j]]] = evt.detail.axis[axes[j]]; + detail[AXIS_LABELS[j]] = evt.detail.axis[axes[j]]; } component.el.emit(buttonTypes[i] + 'moved', detail); } diff --git a/tests/components/hand-controls.test.js b/tests/components/hand-controls.test.js index 855c32461f6..90cf9b9ef15 100644 --- a/tests/components/hand-controls.test.js +++ b/tests/components/hand-controls.test.js @@ -41,6 +41,23 @@ suite('hand-controls', function () { trackedControls = el.components['tracked-controls']; trackedControls.controller = {id: 'Foobar', connected: true}; + component.pressedButtons['grip'] = true; + component.pressedButtons['trigger'] = false; + component.pressedButtons['trackpad'] = true; + component.pressedButtons['thumbstick'] = false; + component.pressedButtons['menu'] = false; + component.pressedButtons['AorX'] = false; + component.pressedButtons['BorY'] = false; + component.pressedButtons['surface'] = false; + assert.equal(component.determineGesture(), 'Point'); + }); + + test('makes point gesture on vive', function () { + var trackedControls; + el.setAttribute('tracked-controls', ''); + trackedControls = el.components['tracked-controls']; + trackedControls.controller = {id: 'OpenVR Gamepad', connected: true}; + component.pressedButtons['grip'] = false; component.pressedButtons['trigger'] = false; component.pressedButtons['trackpad'] = true; @@ -58,6 +75,23 @@ suite('hand-controls', function () { trackedControls = el.components['tracked-controls']; trackedControls.controller = {id: 'Foobar', connected: true}; + component.pressedButtons['grip'] = true; + component.pressedButtons['trigger'] = true; + component.pressedButtons['trackpad'] = true; + component.pressedButtons['thumbstick'] = false; + component.pressedButtons['menu'] = false; + component.pressedButtons['AorX'] = false; + component.pressedButtons['BorY'] = false; + component.pressedButtons['surface'] = false; + assert.equal(component.determineGesture(), 'Fist'); + }); + + test('makes fist gesture on vive', function () { + var trackedControls; + el.setAttribute('tracked-controls', ''); + trackedControls = el.components['tracked-controls']; + trackedControls.controller = {id: 'OpenVR Gamepad', connected: true}; + component.pressedButtons['grip'] = true; component.pressedButtons['trigger'] = false; component.pressedButtons['trackpad'] = false; diff --git a/tests/components/laser-controls.test.js b/tests/components/laser-controls.test.js index ee14e3989ac..f0e70c490f4 100644 --- a/tests/components/laser-controls.test.js +++ b/tests/components/laser-controls.test.js @@ -19,6 +19,7 @@ suite('laser-controls', function () { assert.ok(el.components['gearvr-controls']); assert.ok(el.components['oculus-touch-controls']); assert.ok(el.components['vive-controls']); + assert.ok(el.components['windows-motion-controls']); }); test('does not inject cursor yet', function () { diff --git a/tests/components/windows-motion-controls.test.js b/tests/components/windows-motion-controls.test.js new file mode 100644 index 00000000000..c1d72c139d1 --- /dev/null +++ b/tests/components/windows-motion-controls.test.js @@ -0,0 +1,574 @@ +/* global assert, process, setup, suite, test, Event */ +var entityFactory = require('../helpers').entityFactory; + +suite('windows-motion-controls', function () { + var el; + var component; + + var MOCKS = { + AXIS_VALUES_VALID: [0.1, 0.2, 0.3, 0.4], + AXIS_THUMBSTICK_X: 0, + AXIS_THUMBSTICK_Y: 1, + AXIS_TRACKPAD_X: 2, + AXIS_TRACKPAD_Y: 3, + HAND_LEFT: 'left', + HAND_RIGHT: 'right', + HAND_DEFAULT: 'right', + HAND_UNHANDED: '' + }; + + setup(function (done) { + el = this.el = entityFactory(); + el.setAttribute('windows-motion-controls', ''); + el.addEventListener('loaded', function () { + component = el.components['windows-motion-controls']; + // Stub so we don't actually make calls to load the meshes from the remote CDN in every test. + component.loadModel = function () { }; + done(); + }); + }); + + suite('checkIfControllerPresent', function () { + // Test that we don't listen to a-frame emitted events if the component doesn't have + // a controller present. + test('removes event listeners if controllers not present', function () { + var addEventListenersSpy = this.sinon.spy(component, 'addEventListeners'); + var injectTrackedControlsSpy = this.sinon.spy(component, 'injectTrackedControls'); + var removeEventListenersSpy = this.sinon.spy(component, 'removeEventListeners'); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.notOk(injectTrackedControlsSpy.called, 'injectTrackedControls not called'); + assert.notOk(addEventListenersSpy.called, 'addEventListeners not called'); + assert.ok(removeEventListenersSpy.called, 'removeEventListeners called'); + assert.strictEqual(component.controllerPresent, false, 'contollers not present'); + }); + + test('does not call removeEventListeners multiple times', function () { + var addEventListenersSpy = this.sinon.spy(component, 'addEventListeners'); + var injectTrackedControlsSpy = this.sinon.spy(component, 'injectTrackedControls'); + var removeEventListenersSpy = this.sinon.spy(component, 'removeEventListeners'); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + component.controllerPresent = false; + + component.checkIfControllerPresent(); + + assert.notOk(injectTrackedControlsSpy.called, 'injectTrackedControls not called'); + assert.notOk(addEventListenersSpy.called, 'addEventListeners not called'); + assert.notOk(removeEventListenersSpy.called, 'removeEventListeners not called'); + assert.strictEqual(component.controllerPresent, false, 'contollers not present'); + }); + + test('attach events if controller is newly present', function () { + var addEventListenersSpy = this.sinon.spy(component, 'addEventListeners'); + var injectTrackedControlsSpy = this.sinon.spy(component, 'injectTrackedControls'); + var removeEventListenersSpy = this.sinon.spy(component, 'removeEventListeners'); + + // Mock isControllerPresent to return true. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.ok(injectTrackedControlsSpy.called, 'Inject'); + assert.ok(addEventListenersSpy.called, 'Add'); + assert.notOk(removeEventListenersSpy.called, 'Remove'); + assert.ok(component.controllerPresent, 'controllers present'); + }); + + test('does not detect presence of controller with missing id suffix', function () { + // Mock isControllerPresent to return true. + el.sceneEl.systems['tracked-controls'].controllers = [ + {id: 'Spatial Controller (Spatial Interaction Source)', index: 0, hand: MOCKS.HAND_LEFT, pose: {}} + ]; + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.notOk(component.controllerPresent, 'controllers present'); + }); + + test('does not detect presence of controller with unknown device ID', function () { + // Mock isControllerPresent to return true. + el.sceneEl.systems['tracked-controls'].controllers = [ + {id: 'unknown', index: 0, hand: MOCKS.HAND_LEFT, pose: {}} + ]; + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.notOk(component.controllerPresent, 'controllers present'); + }); + + test('does not detect presence of controller with wrong hand', function () { + // Mock isControllerPresent to return false. + component.data.hand = MOCKS.HAND_RIGHT; + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.notOk(component.controllerPresent, 'controllers present'); + }); + + test('does not detect presence of controller in second pair with not enough connected', function () { + // Mock isControllerPresent to return false. + component.data.pair = 1; + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT, MOCKS.HAND_RIGHT); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.notOk(component.controllerPresent, 'controllers present'); + }); + + test('detects presence of controller in third pair', function () { + // Mock isControllerPresent to return true. + component.data.pair = 2; + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT, MOCKS.HAND_RIGHT, MOCKS.HAND_LEFT, MOCKS.HAND_RIGHT, MOCKS.HAND_LEFT, MOCKS.HAND_RIGHT); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.ok(component.controllerPresent, 'controllers present'); + }); + + test('detects presence of controller in second pair', function () { + // Mock isControllerPresent to return true. + component.data.pair = 1; + + detect('right'); + detect('left'); + + function detect (hand) { + component.data.hand = hand; + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(hand, hand); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.ok(component.controllerPresent, hand + ' controllers present'); + } + }); + + test('detects presence of controller in second pair of unhanded', function () { + // Mock isControllerPresent to return true. + component.data.pair = 1; + + detect('right'); + detect('left'); + + function detect (hand) { + component.data.hand = hand; + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList('', '', '', ''); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.ok(component.controllerPresent, hand + ' controllers present'); + } + }); + + test('does not detect presence of controller in second pair of unhanded with too few connected', function () { + // Mock isControllerPresent to return true. + component.data.pair = 1; + + detect('right'); + detect('left'); + + function detect (hand) { + component.data.hand = hand; + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList('', ''); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.notOk(component.controllerPresent, hand + ' controllers present'); + } + }); + + test('detects presence of controller with right hand', function () { + component.data.hand = MOCKS.HAND_RIGHT; + + // Mock isControllerPresent to return false. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.ok(component.controllerPresent, 'controllers present'); + }); + + test('detects presence of right controller with single unhanded', function () { + component.data.hand = MOCKS.HAND_RIGHT; + + // Mock isControllerPresent to return false. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_UNHANDED); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.ok(component.controllerPresent, 'controllers present'); + }); + + test('does not detect presence of left controller with single unhanded', function () { + component.data.hand = MOCKS.HAND_LEFT; + + // Mock isControllerPresent to return false. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_UNHANDED); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.notOk(component.controllerPresent, 'controllers present'); + }); + + test('detects presence of left controller with two unhanded', function () { + component.data.hand = MOCKS.HAND_LEFT; + + // Mock isControllerPresent to return false. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_UNHANDED, MOCKS.HAND_UNHANDED); + + // delete our previously created mock, so component behaves as if it's never + // checked for controller presence previously. + delete component.controllerPresent; + + component.checkIfControllerPresent(); + + assert.ok(component.controllerPresent, 'controllers present'); + }); + + test('does not add/remove event listeners if presence does not change', function () { + var addEventListenersSpy = this.sinon.spy(component, 'addEventListeners'); + var injectTrackedControlsSpy = this.sinon.spy(component, 'injectTrackedControls'); + var removeEventListenersSpy = this.sinon.spy(component, 'removeEventListeners'); + + // Mock isControllerPresent to return true. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); + + // Mock to the state that a gamepad is present. + component.controllerEventsActive = true; + component.controllerPresent = true; + + component.checkIfControllerPresent(); + + assert.notOk(injectTrackedControlsSpy.called, 'injectTrackedControls not called'); + assert.notOk(addEventListenersSpy.called, 'addEventListeners not called'); + assert.notOk(removeEventListenersSpy.called); + assert.ok(component.controllerPresent, 'controllers present'); + }); + + test('removes event listeners if controller disappears', function () { + var addEventListenersSpy = this.sinon.spy(component, 'addEventListeners'); + var injectTrackedControlsSpy = this.sinon.spy(component, 'injectTrackedControls'); + + // Mock to the state that a gamepad is present. + component.controllerEventsActive = true; + component.controllerPresent = true; + + component.checkIfControllerPresent(); + + assert.notOk(injectTrackedControlsSpy.called, 'injectTrackedControls not called'); + assert.notOk(addEventListenersSpy.called, 'addEventListeners not called'); + assert.notOk(component.controllerPresent, 'controllers not present'); + }); + }); + + suite('axismove', function () { + test('emits thumbstick moved on X', function (done) { + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); + // Do the check. + component.checkIfControllerPresent(); + // Install event handler listening for thumbstickmoved. + this.el.addEventListener('thumbstickmoved', function (evt) { + assert.equal(evt.detail.x, MOCKS.AXIS_VALUES_VALID[MOCKS.AXIS_THUMBSTICK_X], 'thumbstick axis X value'); + assert.equal(evt.detail.y, MOCKS.AXIS_VALUES_VALID[MOCKS.AXIS_THUMBSTICK_Y], 'thumbstick axis Y value'); + assert.ok(evt.detail); + done(); + }); + // Emit axismove. + this.el.emit('axismove', createAxisMovedFromChanged(MOCKS.AXIS_THUMBSTICK_X)); + }); + + test('emits thumbstick moved on Y', function (done) { + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); + // Do the check. + component.checkIfControllerPresent(); + // Install event handler listening for thumbstickmoved. + this.el.addEventListener('thumbstickmoved', function (evt) { + assert.equal(evt.detail.x, MOCKS.AXIS_VALUES_VALID[MOCKS.AXIS_THUMBSTICK_X], 'thumbstick axis X value'); + assert.equal(evt.detail.y, MOCKS.AXIS_VALUES_VALID[MOCKS.AXIS_THUMBSTICK_Y], 'thumbstick axis Y value'); + assert.ok(evt.detail); + done(); + }); + // Emit axismove. + this.el.emit('axismove', createAxisMovedFromChanged(MOCKS.AXIS_THUMBSTICK_Y)); + }); + + test('emits trackpad moved on X', function (done) { + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); + // Do the check. + component.checkIfControllerPresent(); + // Install event handler listening for trackpadmoved. + this.el.addEventListener('trackpadmoved', function (evt) { + assert.ok(evt.detail, 'event.detail not null'); + assert.equal(evt.detail.x, MOCKS.AXIS_VALUES_VALID[MOCKS.AXIS_TRACKPAD_X], 'trackpad axis X value'); + assert.equal(evt.detail.y, MOCKS.AXIS_VALUES_VALID[MOCKS.AXIS_TRACKPAD_Y], 'trackpad axis Y value'); + done(); + }); + // Emit axismove. + this.el.emit('axismove', createAxisMovedFromChanged(MOCKS.AXIS_TRACKPAD_X)); + }); + + test('emits trackpad moved on Y', function (done) { + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); + // Do the check. + component.checkIfControllerPresent(); + // Install event handler listening for trackpadmoved. + this.el.addEventListener('trackpadmoved', function (evt) { + assert.ok(evt.detail, 'event.detail not null'); + assert.equal(evt.detail.x, MOCKS.AXIS_VALUES_VALID[MOCKS.AXIS_TRACKPAD_X], 'trackpad axis X value'); + assert.equal(evt.detail.y, MOCKS.AXIS_VALUES_VALID[MOCKS.AXIS_TRACKPAD_Y], 'trackpad axis Y value'); + done(); + }); + // Emit axismove. + this.el.emit('axismove', createAxisMovedFromChanged(MOCKS.AXIS_TRACKPAD_Y)); + }); + + test('does not emit thumbstickmoved if axismove has no changes', function (done) { + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); + // Do the check. + component.checkIfControllerPresent(); + // Fail purposely. + this.el.addEventListener('thumbstickmoved', function (evt) { + assert.notOk(evt.detail, 'event detail null'); + }); + // Emit axismove with no changes. + this.el.emit('axismove', createAxisMovedFromChanged()); + setTimeout(() => { done(); }); + }); + }); + + suite('mesh', function () { + var TEST_URL_MODEL = 'test-url.glb'; + var TEST_URL_DEFAULT = 'default.glb'; + + test('added when controller updated', function () { + var loadModelSpy = this.sinon.spy(component, 'loadModel'); + + // Mock URL + component.createControllerModelUrl = function () { return TEST_URL_MODEL; }; + + // Mock isControllerPresent to return true. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); + + // Perform the test + component.checkIfControllerPresent(); + + assert.strictEqual(loadModelSpy.getCalls().length, 1, 'loadMesh called once'); + assert.strictEqual(TEST_URL_MODEL, loadModelSpy.getCall(0).args[0], 'loadMesh src argument equals expected URL'); + }); + + test('uses correct mesh for left hand', function () { + var loadModelSpy = this.sinon.spy(component, 'loadModel'); + + // Mock isControllerPresent to return true. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); + + // Perform the test + component.checkIfControllerPresent(); + + assert.strictEqual(loadModelSpy.getCalls().length, 1, 'loadModel called once'); + + var arg0 = loadModelSpy.getCall(0).args[0] || ''; + assert.ok(arg0.indexOf('left.glb' !== -1), 'expected left hand GLB file'); + }); + + test('uses correct mesh for right hand', function () { + var loadModelSpy = this.sinon.spy(component, 'loadModel'); + + component.data.hand = MOCKS.HAND_RIGHT; + + // Mock isControllerPresent to return true. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_RIGHT); + + // Perform the test + component.checkIfControllerPresent(); + + assert.strictEqual(loadModelSpy.getCalls().length, 1, 'loadModel called once'); + + var arg0 = loadModelSpy.getCall(0).args[0] || ''; + assert.ok(arg0.indexOf('right.glb' !== -1), 'expected right hand GLB file'); + }); + + test('uses correct mesh for unhanded', function () { + var loadModelSpy = this.sinon.spy(component, 'loadModel'); + + component.data.hand = MOCKS.HAND_RIGHT; + + // Mock isControllerPresent to return true. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_UNHANDED); + + // Perform the test + component.checkIfControllerPresent(); + + assert.strictEqual(loadModelSpy.getCalls().length, 1, 'loadModel called once'); + + var arg0 = loadModelSpy.getCall(0).args[0] || ''; + assert.ok(arg0.indexOf('universal.glb' !== -1), 'expected universal GLB file'); + }); + + test('retries with default model when 404', function () { + var loadModelSpy = this.sinon.spy(component, 'loadModel'); + + // Mock URL to return MODEL first time, DEFAULT thereafter + var url = TEST_URL_MODEL; + component.createControllerModelUrl = function () { + // Update the mocked value so that the next call to this method will return the default URL. + var returnValue = url; + url = TEST_URL_DEFAULT; + return returnValue; + }; + + // Mock isControllerPresent to return true. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_RIGHT); + + // Perform the test + component.checkIfControllerPresent(); + el.emit('model-error', {detail: {src: TEST_URL_MODEL}}); + + assert.ok(loadModelSpy.called, 'loadModel called'); + assert.strictEqual(loadModelSpy.getCalls().length, 2, 'loadMesh called twice'); + assert.strictEqual(TEST_URL_MODEL, loadModelSpy.getCall(0).args[0], 'loadMesh src argument equals expected ID based URL'); + assert.strictEqual(TEST_URL_DEFAULT, loadModelSpy.getCall(1).args[0], 'loadMesh src argument equals expected default URL'); + }); + }); + + suite('buttonchanged', function () { + test('can emit thumbstickchanged event', function (done) { + buttonTestHelper(done, 0, 'thumbstick'); + }); + test('can emit triggerchanged event', function (done) { + buttonTestHelper(done, 1, 'trigger'); + }); + test('can emit gripchanged event', function (done) { + buttonTestHelper(done, 2, 'grip'); + }); + test('can emit menuchanged event', function (done) { + buttonTestHelper(done, 3, 'menu'); + }); + test('can emit trackpadchanged event', function (done) { + buttonTestHelper(done, 4, 'trackpad'); + }); + + function buttonTestHelper (done, buttonIndex, buttonName) { + var state = {value: 0.5, pressed: true, touched: true}; + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_RIGHT); + // Do the check. + component.checkIfControllerPresent(); + // Install event handler listening for changed event. + el.addEventListener(buttonName + 'changed', function (evt) { + assert.ok(evt.detail, 'event.detail not null'); + assert.strictEqual(evt.detail.value, state.value, 'event detail.value'); + assert.strictEqual(evt.detail.pressed, state.pressed, 'event detail.pressed'); + assert.strictEqual(evt.detail.touched, state.touched, 'event detail.touched'); + done(); + }); + // Emit buttonchanged. + el.emit('buttonchanged', {id: buttonIndex, state: state}); + } + }); + + suite('gamepaddisconnected', function () { + test('checks if present on gamepaddisconnected event', function (done) { + var checkIfControllerPresentSpy = this.sinon.spy(component, 'checkIfControllerPresent'); + // Because checkIfControllerPresent may be used in bound form, using this.bind, + // we need to re-create the binding and re-attach the event listeners for our spy to work + component.checkIfControllerPresent = component.checkIfControllerPresent.bind(component); + // Pause and resume to remove and re-attach the event listeners, with our spy. + component.pause(); + component.play(); + // Reset everGotGamepadEvent so we don't think we've looked before. + delete component.everGotGamepadEvent; + // Fire emulated gamepaddisconnected event. + window.dispatchEvent(new Event('gamepaddisconnected')); + + assert.ok(checkIfControllerPresentSpy.called, 'checkIfControllerPresent not called in response to gamepaddisconnected'); + + setTimeout(() => { done(); }); + }); + }); + + // Helper to create an event argument object for the axismove event + function createAxisMovedFromChanged () { + var changed = []; + var i; + + for (i = 0; i < MOCKS.AXIS_VALUES_VALID.length; i++) { + changed.push(false); + } + for (i = 0; i < arguments.length; i++) { + changed[arguments[i]] = true; + } + return { + // Axis values + axis: MOCKS.AXIS_VALUES_VALID, + // Which values changed since the last 'tick' + changed: changed + }; + } + + function createMotionControllersList () { + var controllersList = []; + + for (var i = 0; i < arguments.length; i++) { + controllersList.push( + {id: 'Spatial Controller (Spatial Interaction Source) 045E-065A', index: i, hand: arguments[i], pose: {}} + ); + } + + return controllersList; + } +});