From 89e0fa407bdfb5aebfc84e60d1da2d3e0de2ba24 Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Tue, 1 Aug 2017 19:03:18 -0700 Subject: [PATCH 01/11] Added support for windows mixed reality controllers --- src/components/gltf-model.js | 2 + src/components/hand-controls.js | 33 +- src/components/index.js | 1 + src/components/tracked-controls.js | 48 +- src/components/windows-motion-controls.js | 382 ++++++++++++++ src/utils/tracked-controls.js | 84 ++- tests/components/hand-controls.test.js | 34 ++ .../windows-motion-controls.test.js | 481 ++++++++++++++++++ 8 files changed, 1011 insertions(+), 54 deletions(-) create mode 100644 src/components/windows-motion-controls.js create mode 100644 tests/components/windows-motion-controls.test.js diff --git a/src/components/gltf-model.js b/src/components/gltf-model.js index 79147c72e31..940778dd629 100644 --- a/src/components/gltf-model.js +++ b/src/components/gltf-model.js @@ -26,6 +26,8 @@ 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 () { + 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/tracked-controls.js b/src/components/tracked-controls.js index 5bd45016d68..b3bbe4a071f 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(); @@ -46,6 +50,21 @@ module.exports.Component = registerComponent('tracked-controls', { this.updateGamepad(); }, + update: function () { + var data = this.data; + + // If the component requests a specific hand (the hand property is set), it implies that the ordering of + // controllers in gamepad array does not say anything about their handedness. This means that we need to + // ignore data.controller. + // For cases where hand property is not provided, controller ID -> Hand: 0 is right, 1 is left. (to match Vive) + if (data.hand) { + // This is used only in the case where the gamepads themselves are not handed + this.targetControllerNumber = (data.hand === DEFAULT_HANDEDNESS) ? 0 : 1; + } else { + this.targetControllerNumber = data.controller; + } + }, + tick: function (time, delta) { var mesh = this.el.getObject3D('mesh'); // Update mesh animations. @@ -70,25 +89,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, + this.targetControllerNumber + ); + + // 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..6a3fb445a30 --- /dev/null +++ b/src/components/windows-motion-controls.js @@ -0,0 +1,382 @@ +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'); + +// TODO: Point to final glb assets once they are hosted. For now, place them in this hard coded directory. +var MODEL_BASE_URL = '/examples/assets/models/controllers/wmr/'; +var MODEL_LEFT_FILENAME = 'left.glb'; +var MODEL_RIGHT_FILENAME = 'right.glb'; +var MODEL_UNIVERSAL_FILENAME = '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: 'left'}, + // 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' + } + }, + + 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 + }; + + // 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 + }); + + 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 (!this.data.model || this.el.getAttribute('gltf-model')) { 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; + + 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 + var filename; + if (hand === 'left') filename = MODEL_LEFT_FILENAME; + else if (hand === 'right') filename = MODEL_RIGHT_FILENAME; + else filename = MODEL_UNIVERSAL_FILENAME; + + // Final url + return MODEL_BASE_URL + device + '/' + filename; + }, + + injectTrackedControls: function () { + var data = this.data; + this.el.setAttribute('tracked-controls', { + idPrefix: GAMEPAD_ID_PREFIX, + 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; + + debug('Processing model'); + + // Find the appropriate nodes + var rootNode = controllerObject3D.getObjectByName('RootNode'); + + // 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++) { + var buttonMeshName = this._mapping.buttonMeshNames[this._mapping.buttons[i]]; + if (!buttonMeshName) { + debug('Skipping unknown button at index: ' + i + ' with mapped name: ' + this._mapping.buttons[i]); + continue; + } + + var buttonMesh = rootNode.getObjectByName(buttonMeshName); + if (!buttonMesh) { + warn('Missing button mesh with name: ' + buttonMeshName); + continue; + } + + var buttonMeshInfo = { + index: i, + value: getImmediateChildByName(buttonMesh, 'VALUE'), + pressed: getImmediateChildByName(buttonMesh, 'PRESSED'), + unpressed: getImmediateChildByName(buttonMesh, 'UNPRESSED') + }; + if (buttonMeshInfo.value && buttonMeshInfo.pressed && buttonMeshInfo.unpressed) { + loadedMeshInfo.buttonMeshes[this._mapping.buttons[i]] = buttonMeshInfo; + } 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: ' + buttonMeshName + + '(VALUE: ' + !!buttonMeshInfo.value + + ', PRESSED: ' + !!buttonMeshInfo.pressed + + ', UNPRESSED:' + !!buttonMeshInfo.unpressed + + ')'); + } + } + + // Axis Meshes + for (i = 0; i < this._mapping.axisMeshNames.length; i++) { + var axisMeshName = this._mapping.axisMeshNames[i]; + if (!axisMeshName) { + debug('Skipping unknown axis at index: ' + i); + continue; + } + + var axisMesh = rootNode.getObjectByName(axisMeshName); + if (!axisMesh) { + warn('Missing axis mesh with name: ' + axisMeshName); + continue; + } + + var axisMeshInfo = { + index: i, + value: getImmediateChildByName(axisMesh, 'VALUE'), + min: getImmediateChildByName(axisMesh, 'MIN'), + max: getImmediateChildByName(axisMesh, 'MAX') + }; + if (axisMeshInfo.value && axisMeshInfo.min && axisMeshInfo.max) { + loadedMeshInfo.axisMeshes[i] = axisMeshInfo; + } 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: ' + axisMeshName + + '(VALUE: ' + !!axisMeshInfo.value + + ', MIN: ' + !!axisMeshInfo.min + + ', MAX:' + !!axisMeshInfo.max + + ')'); + } + } + } 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 (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(min.quaternion.clone().slerp(max.quaternion, lerpValue)); + target.position.lerpVectors(min.position, max.position, lerpValue); + }, + + lerpButtonTransform: 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(min.quaternion.clone().slerp(max.quaternion, buttonValue)); + target.position.lerpVectors(min.position, max.position, buttonValue); + }, + + 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..35e76a61c85 100644 --- a/src/utils/tracked-controls.js +++ b/src/utils/tracked-controls.js @@ -46,14 +46,19 @@ 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 targetControllerNumber; + + if (!idPrefix) { return false; } + + if (queryObject.hand) { + // This is only used in the case where the gamepads themselves are not handed + targetControllerNumber = (queryObject.hand === DEFAULT_HANDEDNESS) ? 0 : 1; + } else { + targetControllerNumber = queryObject.index || 0; + } trackedControlsSystem = sceneEl && sceneEl.systems['tracked-controls']; if (!trackedControlsSystem) { return false; } @@ -61,26 +66,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, targetControllerNumber); +} + +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 targetControllerNumber + * If filterHand is not set, we skip until we have found the nth matching controller, where n equals targetControllerNumber + * + * The method should be called with one of: [filterIdExact, filterIdPrefix] AND one or both of: [filterHand, targetControllerNumber] + * + * @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} targetControllerNumber - Find the nth matching controller, where n equals targetControllerNumber. defaults to 0. + */ +function findMatchingController (controllers, filterIdExact, filterIdPrefix, filterHand, targetControllerNumber) { + var controller; + var i; + var matchingControllerOccurence = 0; + targetControllerNumber = targetControllerNumber || 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 (filterHand) { + if (filterHand === controller.hand) { + // If the component requests a specific hand and found a matching one, we ignore the + // targetControllerNumber requirement and early exit. + return controller; + } else if (controller.hand) { + continue; + } + // If we reach here, the controller is unhanded - check against targetControllerNumber } - if (isPresent && queryObject.index) { - // Need to use count of gamepads with idPrefix. - isPresent = index === queryObject.index; + // The controller is unhanded, or we are looking for the nth occurence of a matching controller (n equals targetControllerNumber). + if (matchingControllerOccurence === targetControllerNumber) { + return controller; } - if (isPresent) { break; } - // Update count of gamepads with idPrefix. - if (isPrefixMatch) { index++; } + ++matchingControllerOccurence; } - - return isPresent; + return null; } -module.exports.isControllerPresent = isControllerPresent; +module.exports.findMatchingController = findMatchingController; /** * Emit specific `moved` event(s) if axes changed based on original axismoved event. @@ -103,7 +143,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 +151,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..9b355b9812b 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 Vive', 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 Vive', connected: true}; + component.pressedButtons['grip'] = true; component.pressedButtons['trigger'] = false; component.pressedButtons['trackpad'] = false; diff --git a/tests/components/windows-motion-controls.test.js b/tests/components/windows-motion-controls.test.js new file mode 100644 index 00000000000..fccb6065518 --- /dev/null +++ b/tests/components/windows-motion-controls.test.js @@ -0,0 +1,481 @@ +/* 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_UNHANDED: '' + }; + + setup(function (done) { + el = this.el = entityFactory(); + el.setAttribute('windows-motion-controls', 'hand: left'); + 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_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.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. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(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 with right hand', function () { + component.data.hand = MOCKS.HAND_RIGHT; + + // Mock isControllerPresent to return false. + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(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 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_LEFT); + + // 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_LEFT); + // 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_LEFT); + // 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_LEFT); + // 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_LEFT); + // 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_LEFT); + // 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_LEFT); + + // 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_LEFT); + + // 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_LEFT); + + // 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_LEFT); + // 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; + } +}); From 638c6a2a2fad503ba67f291bc8a1d7b8df3e05d5 Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Thu, 31 Aug 2017 16:05:13 -0700 Subject: [PATCH 02/11] Added controller pairs, address PR feedback. - Added concept of controller pairs, with the addition of a 'pair' property on the windows-motion-controls class. This allows you to connect more than 2 controllers at a time. - Added closures to prevent surplus allocations - Minor formatting tweaks --- src/components/tracked-controls.js | 17 +- src/components/windows-motion-controls.js | 155 ++++++++++-------- src/utils/tracked-controls.js | 54 +++--- tests/components/hand-controls.test.js | 4 +- .../windows-motion-controls.test.js | 121 ++++++++++++-- 5 files changed, 218 insertions(+), 133 deletions(-) diff --git a/src/components/tracked-controls.js b/src/components/tracked-controls.js index b3bbe4a071f..dad5395d1a4 100644 --- a/src/components/tracked-controls.js +++ b/src/components/tracked-controls.js @@ -50,21 +50,6 @@ module.exports.Component = registerComponent('tracked-controls', { this.updateGamepad(); }, - update: function () { - var data = this.data; - - // If the component requests a specific hand (the hand property is set), it implies that the ordering of - // controllers in gamepad array does not say anything about their handedness. This means that we need to - // ignore data.controller. - // For cases where hand property is not provided, controller ID -> Hand: 0 is right, 1 is left. (to match Vive) - if (data.hand) { - // This is used only in the case where the gamepads themselves are not handed - this.targetControllerNumber = (data.hand === DEFAULT_HANDEDNESS) ? 0 : 1; - } else { - this.targetControllerNumber = data.controller; - } - }, - tick: function (time, delta) { var mesh = this.el.getObject3D('mesh'); // Update mesh animations. @@ -98,7 +83,7 @@ module.exports.Component = registerComponent('tracked-controls', { data.id, data.idPrefix, data.hand, - this.targetControllerNumber + data.controller ); // Only replace the stored controller if we find a new one. diff --git a/src/components/windows-motion-controls.js b/src/components/windows-motion-controls.js index 6a3fb445a30..79b7fd026e7 100644 --- a/src/components/windows-motion-controls.js +++ b/src/components/windows-motion-controls.js @@ -1,3 +1,4 @@ +/* global THREE */ var bind = require('../utils/bind'); var registerComponent = require('../core/component').registerComponent; var controllerUtils = require('../utils/tracked-controls'); @@ -6,11 +7,11 @@ 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; + // TODO: Point to final glb assets once they are hosted. For now, place them in this hard coded directory. var MODEL_BASE_URL = '/examples/assets/models/controllers/wmr/'; -var MODEL_LEFT_FILENAME = 'left.glb'; -var MODEL_RIGHT_FILENAME = 'right.glb'; -var MODEL_UNIVERSAL_FILENAME = 'universal.glb'; +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]+)$/; @@ -23,14 +24,17 @@ var GAMEPAD_ID_PATTERN = /([0-9a-zA-Z]+-[0-9a-zA-Z]+)$/; */ module.exports.Component = registerComponent('windows-motion-controls', { schema: { - hand: {default: 'left'}, + 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: { + mapping: { // A-Frame specific semantic axis names axes: {'thumbstick': [0, 1], 'trackpad': [2, 3]}, // A-Frame specific semantic button names @@ -77,7 +81,7 @@ module.exports.Component = registerComponent('windows-motion-controls', { this.bindMethods(); // Cache for submeshes that we have looked up by name. - this._loadedMeshInfo = { + this.loadedMeshInfo = { buttonMeshes: null, axisMeshes: null }; @@ -115,7 +119,8 @@ module.exports.Component = registerComponent('windows-motion-controls', { checkIfControllerPresent: function () { this.checkControllerPresentAndSetup(this, GAMEPAD_ID_PREFIX, { - hand: this.data.hand + hand: this.data.hand, + index: this.data.pair }); if (this.data.hideDisconnected) { @@ -156,6 +161,7 @@ module.exports.Component = registerComponent('windows-motion-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 @@ -170,10 +176,7 @@ module.exports.Component = registerComponent('windows-motion-controls', { } // Hand - var filename; - if (hand === 'left') filename = MODEL_LEFT_FILENAME; - else if (hand === 'right') filename = MODEL_RIGHT_FILENAME; - else filename = MODEL_UNIVERSAL_FILENAME; + filename = MODEL_FILENAMES[hand] || MODEL_FILENAMES.default; // Final url return MODEL_BASE_URL + device + '/' + filename; @@ -183,6 +186,7 @@ module.exports.Component = registerComponent('windows-motion-controls', { var data = this.data; this.el.setAttribute('tracked-controls', { idPrefix: GAMEPAD_ID_PREFIX, + controller: data.pair, hand: data.hand }); @@ -221,8 +225,11 @@ module.exports.Component = registerComponent('windows-motion-controls', { onModelLoaded: function (evt) { var controllerObject3D = evt.detail.model; - var loadedMeshInfo = this._loadedMeshInfo; + var loadedMeshInfo = this.loadedMeshInfo; var i; + var meshName; + var mesh; + var meshInfo; debug('Processing model'); @@ -236,65 +243,65 @@ module.exports.Component = registerComponent('windows-motion-controls', { // 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++) { - var buttonMeshName = this._mapping.buttonMeshNames[this._mapping.buttons[i]]; - if (!buttonMeshName) { - debug('Skipping unknown button at index: ' + i + ' with mapped name: ' + this._mapping.buttons[i]); + for (i = 0; i < this.mapping.buttons.length; i++) { + meshName = this.mapping.meshNames[this.mapping.buttons[i]]; + if (!meshName) { + debug('Skipping unknown button at index: ' + i + ' with mapped name: ' + this.mapping.buttons[i]); continue; } - var buttonMesh = rootNode.getObjectByName(buttonMeshName); - if (!buttonMesh) { - warn('Missing button mesh with name: ' + buttonMeshName); + mesh = rootNode.getObjectByName(meshName); + if (!mesh) { + warn('Missing button mesh with name: ' + meshName); continue; } - var buttonMeshInfo = { + meshInfo = { index: i, - value: getImmediateChildByName(buttonMesh, 'VALUE'), - pressed: getImmediateChildByName(buttonMesh, 'PRESSED'), - unpressed: getImmediateChildByName(buttonMesh, 'UNPRESSED') + value: getImmediateChildByName(mesh, 'VALUE'), + pressed: getImmediateChildByName(mesh, 'PRESSED'), + unpressed: getImmediateChildByName(mesh, 'UNPRESSED') }; - if (buttonMeshInfo.value && buttonMeshInfo.pressed && buttonMeshInfo.unpressed) { - loadedMeshInfo.buttonMeshes[this._mapping.buttons[i]] = buttonMeshInfo; + 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: ' + buttonMeshName + - '(VALUE: ' + !!buttonMeshInfo.value + - ', PRESSED: ' + !!buttonMeshInfo.pressed + - ', UNPRESSED:' + !!buttonMeshInfo.unpressed + + 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++) { - var axisMeshName = this._mapping.axisMeshNames[i]; - if (!axisMeshName) { + for (i = 0; i < this.mapping.meshNames.length; i++) { + meshName = this.mapping.meshNames[i]; + if (!meshName) { debug('Skipping unknown axis at index: ' + i); continue; } - var axisMesh = rootNode.getObjectByName(axisMeshName); - if (!axisMesh) { - warn('Missing axis mesh with name: ' + axisMeshName); + mesh = rootNode.getObjectByName(meshName); + if (!mesh) { + warn('Missing axis mesh with name: ' + meshName); continue; } - var axisMeshInfo = { + meshInfo = { index: i, - value: getImmediateChildByName(axisMesh, 'VALUE'), - min: getImmediateChildByName(axisMesh, 'MIN'), - max: getImmediateChildByName(axisMesh, 'MAX') + value: getImmediateChildByName(mesh, 'VALUE'), + min: getImmediateChildByName(mesh, 'MIN'), + max: getImmediateChildByName(mesh, 'MAX') }; - if (axisMeshInfo.value && axisMeshInfo.min && axisMeshInfo.max) { - loadedMeshInfo.axisMeshes[i] = axisMeshInfo; + 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: ' + axisMeshName + - '(VALUE: ' + !!axisMeshInfo.value + - ', MIN: ' + !!axisMeshInfo.min + - ', MAX:' + !!axisMeshInfo.max + + warn('Missing axis submesh under mesh with name: ' + meshName + + '(VALUE: ' + !!meshInfo.value + + ', MIN: ' + !!meshInfo.min + + ', MAX:' + !!meshInfo.max + ')'); } } @@ -316,38 +323,44 @@ module.exports.Component = registerComponent('windows-motion-controls', { } }, - lerpAxisTransform: function (axis, axisValue) { - var axisMeshInfo = this._loadedMeshInfo.axisMeshes[axis]; - if (!axisMeshInfo) return; + 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; + 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(min.quaternion.clone().slerp(max.quaternion, lerpValue)); - target.position.lerpVectors(min.position, max.position, lerpValue); - }, + // 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 (buttonName, buttonValue) { - var buttonMeshInfo = this._loadedMeshInfo.buttonMeshes[buttonName]; - if (!buttonMeshInfo) return; + 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; + var min = buttonMeshInfo.unpressed; + var max = buttonMeshInfo.pressed; + var target = buttonMeshInfo.value; - target.setRotationFromQuaternion(min.quaternion.clone().slerp(max.quaternion, buttonValue)); - target.position.lerpVectors(min.position, max.position, buttonValue); - }, + target.setRotationFromQuaternion(quaternion.copy(min.quaternion).slerp(max.quaternion, buttonValue)); + target.position.lerpVectors(min.position, max.position, buttonValue); + }; + })(), onButtonChanged: function (evt) { - var buttonName = this._mapping.buttons[evt.detail.id]; + var buttonName = this.mapping.buttons[evt.detail.id]; if (buttonName) { // Update the button mesh transform - if (this._loadedMeshInfo && this._loadedMeshInfo.buttonMeshes) { + if (this.loadedMeshInfo && this.loadedMeshInfo.buttonMeshes) { this.lerpButtonTransform(buttonName, evt.detail.state.value); } @@ -357,7 +370,7 @@ module.exports.Component = registerComponent('windows-motion-controls', { }, onButtonEvent: function (evt, evtName) { - var buttonName = this._mapping.buttons[evt.detail.id]; + var buttonName = this.mapping.buttons[evt.detail.id]; debug('onButtonEvent(' + evt.detail.id + ', ' + evtName + ')'); if (buttonName) { @@ -367,16 +380,16 @@ module.exports.Component = registerComponent('windows-motion-controls', { }, onAxisMoved: function (evt) { - var numAxes = this._mapping.axisMeshNames.length; + var numAxes = this.mapping.axisMeshNames.length; // Only attempt to update meshes if we have valid data. - if (this._loadedMeshInfo && this._loadedMeshInfo.axisMeshes) { + 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); + this.emitIfAxesChanged(this, this.mapping.axes, evt); } }); diff --git a/src/utils/tracked-controls.js b/src/utils/tracked-controls.js index 35e76a61c85..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. @@ -49,24 +50,17 @@ function isControllerPresent (component, idPrefix, queryObject) { var gamepads; var sceneEl = component.el.sceneEl; var trackedControlsSystem; - var targetControllerNumber; + var filterControllerIndex = queryObject.index || 0; if (!idPrefix) { return false; } - if (queryObject.hand) { - // This is only used in the case where the gamepads themselves are not handed - targetControllerNumber = (queryObject.hand === DEFAULT_HANDEDNESS) ? 0 : 1; - } else { - targetControllerNumber = queryObject.index || 0; - } - trackedControlsSystem = sceneEl && sceneEl.systems['tracked-controls']; if (!trackedControlsSystem) { return false; } gamepads = trackedControlsSystem.controllers; if (!gamepads.length) { return false; } - return !!findMatchingController(gamepads, null, idPrefix, queryObject.hand, targetControllerNumber); + return !!findMatchingController(gamepads, null, idPrefix, queryObject.hand, filterControllerIndex); } module.exports.isControllerPresent = isControllerPresent; @@ -78,46 +72,46 @@ module.exports.isControllerPresent = isControllerPresent; * 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 targetControllerNumber - * If filterHand is not set, we skip until we have found the nth matching controller, where n equals targetControllerNumber + * 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, targetControllerNumber] + * 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} targetControllerNumber - Find the nth matching controller, where n equals targetControllerNumber. defaults to 0. + * @param {object} filterControllerIndex - Find the nth matching controller, where n equals filterControllerIndex. defaults to 0. */ -function findMatchingController (controllers, filterIdExact, filterIdPrefix, filterHand, targetControllerNumber) { +function findMatchingController (controllers, filterIdExact, filterIdPrefix, filterHand, filterControllerIndex) { var controller; var i; var matchingControllerOccurence = 0; - targetControllerNumber = targetControllerNumber || 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 (filterHand) { - if (filterHand === controller.hand) { - // If the component requests a specific hand and found a matching one, we ignore the - // targetControllerNumber requirement and early exit. - return controller; - } else if (controller.hand) { - continue; - } - // If we reach here, the controller is unhanded - check against targetControllerNumber + 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); } - // The controller is unhanded, or we are looking for the nth occurence of a matching controller (n equals targetControllerNumber). - if (matchingControllerOccurence === targetControllerNumber) { + + // We are looking for the nth occurence of a matching controller (n equals targetControllerMatch). + if (matchingControllerOccurence === targetControllerMatch) { return controller; } ++matchingControllerOccurence; } - return null; + return undefined; } module.exports.findMatchingController = findMatchingController; diff --git a/tests/components/hand-controls.test.js b/tests/components/hand-controls.test.js index 9b355b9812b..90cf9b9ef15 100644 --- a/tests/components/hand-controls.test.js +++ b/tests/components/hand-controls.test.js @@ -56,7 +56,7 @@ suite('hand-controls', function () { var trackedControls; el.setAttribute('tracked-controls', ''); trackedControls = el.components['tracked-controls']; - trackedControls.controller = {id: 'OpenVR Vive', connected: true}; + trackedControls.controller = {id: 'OpenVR Gamepad', connected: true}; component.pressedButtons['grip'] = false; component.pressedButtons['trigger'] = false; @@ -90,7 +90,7 @@ suite('hand-controls', function () { var trackedControls; el.setAttribute('tracked-controls', ''); trackedControls = el.components['tracked-controls']; - trackedControls.controller = {id: 'OpenVR Vive', connected: true}; + trackedControls.controller = {id: 'OpenVR Gamepad', connected: true}; component.pressedButtons['grip'] = true; component.pressedButtons['trigger'] = false; diff --git a/tests/components/windows-motion-controls.test.js b/tests/components/windows-motion-controls.test.js index fccb6065518..c1d72c139d1 100644 --- a/tests/components/windows-motion-controls.test.js +++ b/tests/components/windows-motion-controls.test.js @@ -13,12 +13,13 @@ suite('windows-motion-controls', function () { 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', 'hand: left'); + 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. @@ -70,7 +71,7 @@ suite('windows-motion-controls', function () { var removeEventListenersSpy = this.sinon.spy(component, 'removeEventListeners'); // Mock isControllerPresent to return true. - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + 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. @@ -116,7 +117,22 @@ suite('windows-motion-controls', function () { test('does not detect presence of controller with wrong hand', function () { // Mock isControllerPresent to return false. - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_RIGHT); + 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. @@ -127,11 +143,88 @@ suite('windows-motion-controls', function () { 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_RIGHT); + 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. @@ -193,7 +286,7 @@ suite('windows-motion-controls', function () { var removeEventListenersSpy = this.sinon.spy(component, 'removeEventListeners'); // Mock isControllerPresent to return true. - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); // Mock to the state that a gamepad is present. component.controllerEventsActive = true; @@ -225,7 +318,7 @@ suite('windows-motion-controls', function () { suite('axismove', function () { test('emits thumbstick moved on X', function (done) { - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); // Do the check. component.checkIfControllerPresent(); // Install event handler listening for thumbstickmoved. @@ -240,7 +333,7 @@ suite('windows-motion-controls', function () { }); test('emits thumbstick moved on Y', function (done) { - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); // Do the check. component.checkIfControllerPresent(); // Install event handler listening for thumbstickmoved. @@ -255,7 +348,7 @@ suite('windows-motion-controls', function () { }); test('emits trackpad moved on X', function (done) { - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); // Do the check. component.checkIfControllerPresent(); // Install event handler listening for trackpadmoved. @@ -270,7 +363,7 @@ suite('windows-motion-controls', function () { }); test('emits trackpad moved on Y', function (done) { - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); // Do the check. component.checkIfControllerPresent(); // Install event handler listening for trackpadmoved. @@ -285,7 +378,7 @@ suite('windows-motion-controls', function () { }); test('does not emit thumbstickmoved if axismove has no changes', function (done) { - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); // Do the check. component.checkIfControllerPresent(); // Fail purposely. @@ -309,7 +402,7 @@ suite('windows-motion-controls', function () { component.createControllerModelUrl = function () { return TEST_URL_MODEL; }; // Mock isControllerPresent to return true. - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); // Perform the test component.checkIfControllerPresent(); @@ -322,7 +415,7 @@ suite('windows-motion-controls', function () { var loadModelSpy = this.sinon.spy(component, 'loadModel'); // Mock isControllerPresent to return true. - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_DEFAULT); // Perform the test component.checkIfControllerPresent(); @@ -380,7 +473,7 @@ suite('windows-motion-controls', function () { }; // Mock isControllerPresent to return true. - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_RIGHT); // Perform the test component.checkIfControllerPresent(); @@ -412,7 +505,7 @@ suite('windows-motion-controls', function () { function buttonTestHelper (done, buttonIndex, buttonName) { var state = {value: 0.5, pressed: true, touched: true}; - el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_LEFT); + el.sceneEl.systems['tracked-controls'].controllers = createMotionControllersList(MOCKS.HAND_RIGHT); // Do the check. component.checkIfControllerPresent(); // Install event handler listening for changed event. From 35243a5b379025a2af8234e3eaad956f8f806143 Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Fri, 1 Sep 2017 13:12:57 -0700 Subject: [PATCH 03/11] Added warning when gltf-model fails to load from the given source. --- docs/components/gltf-model.md | 1 + src/components/gltf-model.js | 2 ++ 2 files changed, 3 insertions(+) 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/src/components/gltf-model.js b/src/components/gltf-model.js index 940778dd629..6febe32d1a3 100644 --- a/src/components/gltf-model.js +++ b/src/components/gltf-model.js @@ -1,5 +1,6 @@ var registerComponent = require('../core/component').registerComponent; var THREE = require('../lib/three'); +var warn = utils.debug('components:windows-motion-controls:warn'); /** * glTF model loader. @@ -27,6 +28,7 @@ module.exports.Component = registerComponent('gltf-model', { el.setObject3D('mesh', self.model); el.emit('model-loaded', {format: 'gltf', model: self.model}); }, undefined /* onProgress */, function gltfFailed () { + warn('Failed to glTF-model: ' + src); el.emit('model-error', {format: 'gltf', src: src}); }); }, From b391904d2588cce1687c90d654683164d633f04b Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Fri, 1 Sep 2017 18:30:55 -0700 Subject: [PATCH 04/11] Fixed missing include --- src/components/gltf-model.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/gltf-model.js b/src/components/gltf-model.js index 6febe32d1a3..7b9015d81fe 100644 --- a/src/components/gltf-model.js +++ b/src/components/gltf-model.js @@ -1,5 +1,6 @@ var registerComponent = require('../core/component').registerComponent; var THREE = require('../lib/three'); +var utils = require('../utils/'); var warn = utils.debug('components:windows-motion-controls:warn'); /** From a654c50d0d9697869f43d6d0354c42e813231f83 Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Fri, 1 Sep 2017 18:44:41 -0700 Subject: [PATCH 05/11] Added Motion Controllers to laser-controls, reading the pointer offset from the downloaded model file. --- docs/components/tracked-controls.md | 5 +- docs/components/windows-motion-controls.md | 60 ++++++++++++++++++++++ src/components/laser-controls.js | 27 ++++++++-- src/components/raycaster.js | 2 +- src/components/windows-motion-controls.js | 50 ++++++++++++++++-- tests/components/laser-controls.test.js | 1 + 6 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 docs/components/windows-motion-controls.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..59fd5eff78b --- /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. | +| controllerdisplayready | The model file is loaded and completed parsing. | + +## Assets + +TBC. diff --git a/src/components/laser-controls.js b/src/components/laser-controls.js index 04a2ca30cae..06bf277320a 100644 --- a/src/components/laser-controls.js +++ b/src/components/laser-controls.js @@ -16,21 +16,33 @@ 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', function (evt) { createRay(evt); }); + el.addEventListener('controllerdisplayready', createRay); + + function createRay (evt) { var controllerConfig = config[evt.detail.name]; if (!controllerConfig) { return; } - el.setAttribute('raycaster', utils.extend({ + var raycasterConfig = utils.extend({ showLine: true - }, controllerConfig.raycaster || {})); + }, controllerConfig.raycaster || {}); + + if (evt.detail.name === 'windows-motion-controls') { + var motionControls = el.components['windows-motion-controls']; + raycasterConfig = utils.extend(raycasterConfig, motionControls.rayOrigin); + raycasterConfig.showLine = motionControls.rayOriginInitialized; + } + + el.setAttribute('raycaster', raycasterConfig); el.setAttribute('cursor', utils.extend({ fuse: false }, controllerConfig.cursor)); - }); + } }, config: { @@ -50,6 +62,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/windows-motion-controls.js b/src/components/windows-motion-controls.js index 79b7fd026e7..64322bb6f56 100644 --- a/src/components/windows-motion-controls.js +++ b/src/components/windows-motion-controls.js @@ -57,7 +57,9 @@ module.exports.Component = registerComponent('windows-motion-controls', { 'grip': 'GRASP', 'thumbstick': 'THUMBSTICK_PRESS', 'trackpad': 'TOUCHPAD_PRESS' - } + }, + pointingPoseMeshName: 'Pointing_Pose', + holdingPoseMeshName: 'Holding_Pose' }, bindMethods: function () { @@ -86,6 +88,13 @@ module.exports.Component = registerComponent('windows-motion-controls', { axisMeshes: null }; + // Pointing poses + this.rayOrigin = { + origin: new THREE.Vector3(), + direction: new THREE.Vector3(0, 0, -1) + }; + this.rayOriginInitialized = !this.data.model; + // Stored on object to allow for mocking in tests this.emitIfAxesChanged = controllerUtils.emitIfAxesChanged; this.checkControllerPresentAndSetup = controllerUtils.checkControllerPresentAndSetup; @@ -212,6 +221,7 @@ module.exports.Component = registerComponent('windows-motion-controls', { this.loadModel(defaultUrl); } else { warn('Failed to load default controller model.'); + this.rayOriginInitialized = true; } }, @@ -230,11 +240,13 @@ module.exports.Component = registerComponent('windows-motion-controls', { 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 = {}; @@ -244,7 +256,7 @@ module.exports.Component = registerComponent('windows-motion-controls', { if (rootNode) { // Button Meshes for (i = 0; i < this.mapping.buttons.length; i++) { - meshName = this.mapping.meshNames[this.mapping.buttons[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; @@ -275,8 +287,8 @@ module.exports.Component = registerComponent('windows-motion-controls', { } // Axis Meshes - for (i = 0; i < this.mapping.meshNames.length; i++) { - meshName = this.mapping.meshNames[i]; + for (i = 0; i < this.mapping.axisMeshNames.length; i++) { + meshName = this.mapping.axisMeshNames[i]; if (!meshName) { debug('Skipping unknown axis at index: ' + i); continue; @@ -305,6 +317,36 @@ module.exports.Component = registerComponent('windows-motion-controls', { ')'); } } + + // 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.'); + } + + // Record that our pointing ray is now accurate. + this.rayOriginInitialized = true; + this.el.emit('controllerdisplayready', {name: 'windows-motion-controls'}); } else { warn('No node with name "RootNode" in controller glTF.'); } 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 () { From 6043da30ceeb011974b770b49bd5067e5f364e0f Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Fri, 1 Sep 2017 18:47:37 -0700 Subject: [PATCH 06/11] Added references to the motion controllers in the documentation --- docs/components/laser-controls.md | 2 ++ docs/introduction/interactions-and-controllers.md | 2 ++ 2 files changed, 4 insertions(+) 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/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: From c57dfe95f7da86fada92395ebd8a04ba8d4a80d7 Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Tue, 5 Sep 2017 21:59:00 -0700 Subject: [PATCH 07/11] Fixed erroneous debug category, added more verbose error messages. --- src/components/gltf-model.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/gltf-model.js b/src/components/gltf-model.js index 7b9015d81fe..cbec4926f90 100644 --- a/src/components/gltf-model.js +++ b/src/components/gltf-model.js @@ -1,7 +1,7 @@ var registerComponent = require('../core/component').registerComponent; var THREE = require('../lib/three'); var utils = require('../utils/'); -var warn = utils.debug('components:windows-motion-controls:warn'); +var warn = utils.debug('components:gltf-model:warn'); /** * glTF model loader. @@ -28,8 +28,9 @@ 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 () { - warn('Failed to glTF-model: ' + src); + }, 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}); }); }, From c5df51e70dff4f830ee2bd9a6d2ebc0df39e32cf Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Tue, 5 Sep 2017 22:00:29 -0700 Subject: [PATCH 08/11] Renamed event to controllermodelready, now passing ray origin data as an event parameter. --- docs/components/windows-motion-controls.md | 2 +- src/components/laser-controls.js | 15 +++++++++------ src/components/windows-motion-controls.js | 10 +++++----- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/components/windows-motion-controls.md b/docs/components/windows-motion-controls.md index 59fd5eff78b..a7995d19d0b 100644 --- a/docs/components/windows-motion-controls.md +++ b/docs/components/windows-motion-controls.md @@ -53,7 +53,7 @@ to the pressed buttons (trigger, grip, menu, thumbstick, trackpad) and moved axe | trackpadup | Trackpad released. | | trackpadchanged | Trackpad button changed. | | trackpadmoved | Trackpad axis moved. | -| controllerdisplayready | The model file is loaded and completed parsing. | +| controllermodelready | The model file is loaded and completed parsing. | ## Assets diff --git a/src/components/laser-controls.js b/src/components/laser-controls.js index 06bf277320a..f8bb06ce072 100644 --- a/src/components/laser-controls.js +++ b/src/components/laser-controls.js @@ -19,22 +19,25 @@ registerComponent('laser-controls', { el.setAttribute('windows-motion-controls', {hand: data.hand}); // Wait for controller to connect, or have a valid pointing pose, before creating ray - el.addEventListener('controllerconnected', function (evt) { createRay(evt); }); - el.addEventListener('controllerdisplayready', createRay); + el.addEventListener('controllerconnected', createRay); + el.addEventListener('controllermodelready', createRay); function createRay (evt) { var controllerConfig = config[evt.detail.name]; if (!controllerConfig) { return; } + // 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 || {}); - if (evt.detail.name === 'windows-motion-controls') { - var motionControls = el.components['windows-motion-controls']; - raycasterConfig = utils.extend(raycasterConfig, motionControls.rayOrigin); - raycasterConfig.showLine = motionControls.rayOriginInitialized; + // The controllermodelready event contains a rayOrigin that takes into account + // offsets specific to the loaded model. + if (evt.detail.rayOrigin) { + raycasterConfig = utils.extend(raycasterConfig, evt.detail.rayOrigin); + raycasterConfig.showLine = true; } el.setAttribute('raycaster', raycasterConfig); diff --git a/src/components/windows-motion-controls.js b/src/components/windows-motion-controls.js index 64322bb6f56..5b530874222 100644 --- a/src/components/windows-motion-controls.js +++ b/src/components/windows-motion-controls.js @@ -93,7 +93,6 @@ module.exports.Component = registerComponent('windows-motion-controls', { origin: new THREE.Vector3(), direction: new THREE.Vector3(0, 0, -1) }; - this.rayOriginInitialized = !this.data.model; // Stored on object to allow for mocking in tests this.emitIfAxesChanged = controllerUtils.emitIfAxesChanged; @@ -221,7 +220,6 @@ module.exports.Component = registerComponent('windows-motion-controls', { this.loadModel(defaultUrl); } else { warn('Failed to load default controller model.'); - this.rayOriginInitialized = true; } }, @@ -344,9 +342,11 @@ module.exports.Component = registerComponent('windows-motion-controls', { debug('Mesh does not contain pointing origin data, defaulting to none.'); } - // Record that our pointing ray is now accurate. - this.rayOriginInitialized = true; - this.el.emit('controllerdisplayready', {name: 'windows-motion-controls'}); + // Emit event stating that our pointing ray is now accurate. + this.el.emit('controllermodelready', { + name: 'windows-motion-controls', + rayOrigin: this.rayOrigin + }); } else { warn('No node with name "RootNode" in controller glTF.'); } From 6c9468f391c8e79870d21bd7fd8453427893455b Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Thu, 7 Sep 2017 13:25:12 -0700 Subject: [PATCH 09/11] CR feedback: Ray direction and offset now explicitly set --- src/components/laser-controls.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/laser-controls.js b/src/components/laser-controls.js index f8bb06ce072..11b95ac3d0d 100644 --- a/src/components/laser-controls.js +++ b/src/components/laser-controls.js @@ -36,7 +36,8 @@ registerComponent('laser-controls', { // The controllermodelready event contains a rayOrigin that takes into account // offsets specific to the loaded model. if (evt.detail.rayOrigin) { - raycasterConfig = utils.extend(raycasterConfig, evt.detail.rayOrigin); + raycasterConfig.origin = evt.detail.rayOrigin.origin; + raycasterConfig.direction = evt.detail.rayOrigin.direction; raycasterConfig.showLine = true; } From 028ad77c0df042d4d07805a440c1f0c9f0e39bbb Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Thu, 7 Sep 2017 16:00:28 -0700 Subject: [PATCH 10/11] Fixed laser-controls beam/line doesn't appear if controller is disconnected/reconnected. Updated MODEL_BASE_URL to point to aframe CDN. --- src/components/laser-controls.js | 6 +++++- src/components/windows-motion-controls.js | 22 +++++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/components/laser-controls.js b/src/components/laser-controls.js index 11b95ac3d0d..e75e09c2214 100644 --- a/src/components/laser-controls.js +++ b/src/components/laser-controls.js @@ -41,7 +41,11 @@ registerComponent('laser-controls', { raycasterConfig.showLine = true; } - el.setAttribute('raycaster', raycasterConfig); + // 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 diff --git a/src/components/windows-motion-controls.js b/src/components/windows-motion-controls.js index 5b530874222..b6bece7fa02 100644 --- a/src/components/windows-motion-controls.js +++ b/src/components/windows-motion-controls.js @@ -9,8 +9,7 @@ var warn = utils.debug('components:windows-motion-controls:warn'); var DEFAULT_HANDEDNESS = require('../constants').DEFAULT_HANDEDNESS; -// TODO: Point to final glb assets once they are hosted. For now, place them in this hard coded directory. -var MODEL_BASE_URL = '/examples/assets/models/controllers/wmr/'; +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) '; @@ -153,7 +152,11 @@ module.exports.Component = registerComponent('windows-motion-controls', { }, updateControllerModel: function () { - if (!this.data.model || this.el.getAttribute('gltf-model')) { return; } + // 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); @@ -343,10 +346,7 @@ module.exports.Component = registerComponent('windows-motion-controls', { } // Emit event stating that our pointing ray is now accurate. - this.el.emit('controllermodelready', { - name: 'windows-motion-controls', - rayOrigin: this.rayOrigin - }); + this.modelReady(); } else { warn('No node with name "RootNode" in controller glTF.'); } @@ -397,6 +397,14 @@ module.exports.Component = registerComponent('windows-motion-controls', { }; })(), + modelReady () { + 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]; From 922797afe076395ea7bb22f630aa2a740356509c Mon Sep 17 00:00:00 2001 From: Lewis Weaver Date: Thu, 7 Sep 2017 17:53:38 -0700 Subject: [PATCH 11/11] Fixed build parser error. --- src/components/windows-motion-controls.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/windows-motion-controls.js b/src/components/windows-motion-controls.js index b6bece7fa02..c16f10ae7c7 100644 --- a/src/components/windows-motion-controls.js +++ b/src/components/windows-motion-controls.js @@ -397,7 +397,7 @@ module.exports.Component = registerComponent('windows-motion-controls', { }; })(), - modelReady () { + modelReady: function () { this.el.emit('controllermodelready', { name: 'windows-motion-controls', model: this.data.model,