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