/* global THREE */ var registerComponent = require('../core/component').registerComponent; var bind = require('../utils/bind'); var trackedControlsUtils = require('../utils/tracked-controls'); var checkControllerPresentAndSetup = trackedControlsUtils.checkControllerPresentAndSetup; var emitIfAxesChanged = trackedControlsUtils.emitIfAxesChanged; var onButtonEvent = trackedControlsUtils.onButtonEvent; 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 isWebXRAvailable = require('../utils/').device.isWebXRAvailable; var GAMEPAD_ID_WEBXR = 'windows-mixed-reality'; var GAMEPAD_ID_WEBVR = 'Spatial Controller (Spatial Interaction Source) '; var GAMEPAD_ID_PATTERN = /([0-9a-zA-Z]+-[0-9a-zA-Z]+)$/; var GAMEPAD_ID_PREFIX = isWebXRAvailable ? GAMEPAD_ID_WEBXR : GAMEPAD_ID_WEBVR; var INPUT_MAPPING_WEBVR = { // 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' }; var INPUT_MAPPING_WEBXR = { // A-Frame specific semantic axis names axes: {'touchpad': [0, 1], 'thumbstick': [2, 3]}, // A-Frame specific semantic button names buttons: ['trigger', 'squeeze', 'touchpad', 'thumbstick', 'menu'], // 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: [ 'TOUCHPAD_TOUCH_X', 'TOUCHPAD_TOUCH_X', 'THUMBSTICK_X', 'THUMBSTICK_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', 'squeeze': 'GRASP', 'thumbstick': 'THUMBSTICK_PRESS', 'touchpad': 'TOUCHPAD_PRESS' }, pointingPoseMeshName: 'POINTING_POSE' }; var INPUT_MAPPING = isWebXRAvailable ? INPUT_MAPPING_WEBXR : INPUT_MAPPING_WEBVR; /** * Windows Motion Controller controls. * Interface with Windows Motion Controller controllers and map Gamepad events to * controller buttons: trackpad, trigger, grip, menu, thumbstick * Load a controller model and transform 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: INPUT_MAPPING, 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; var el = this.el; this.onButtonChanged = bind(this.onButtonChanged, this); this.onButtonDown = function (evt) { onButtonEvent(evt.detail.id, 'down', self); }; this.onButtonUp = function (evt) { onButtonEvent(evt.detail.id, 'up', self); }; this.onButtonTouchStart = function (evt) { onButtonEvent(evt.detail.id, 'touchstart', self); }; this.onButtonTouchEnd = function (evt) { onButtonEvent(evt.detail.id, 'touchend', self); }; this.onControllerConnected = function () { self.setModelVisibility(true); }; this.onControllerDisconnected = function () { self.setModelVisibility(false); }; 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), createdFromMesh: false }; el.addEventListener('controllerconnected', this.onControllerConnected); el.addEventListener('controllerdisconnected', this.onControllerDisconnected); }, 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 () { checkControllerPresentAndSetup(this, GAMEPAD_ID_PREFIX, { hand: this.data.hand, index: this.data.pair, iterateControllerProfiles: true }); }, play: function () { this.checkIfControllerPresent(); this.addControllersUpdateListener(); }, pause: function () { this.removeEventListeners(); this.removeControllersUpdateListener(); }, 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.rayOrigin.createdFromMesh) { 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 && !window.hasNativeWebXRImplementation) { // 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, armModel: false }); 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) { // 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 rootNode = this.controllerModel = evt.detail.model; var loadedMeshInfo = this.loadedMeshInfo; var i; var meshName; var mesh; var meshInfo; debug('Processing model'); // 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 + ')'); } } this.calculateRayOriginFromMesh(rootNode); // Determine if the model has to be visible or not. this.setModelVisibility(); } 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; } }, calculateRayOriginFromMesh: (function () { var quaternion = new THREE.Quaternion(); return function (rootNode) { var mesh; // Calculate the pointer pose (used for rays), by applying the world transform of th POINTER_POSE node // in the glTF (assumes that root node is at world origin) this.rayOrigin.origin.set(0, 0, 0); this.rayOrigin.direction.set(0, 0, -1); this.rayOrigin.createdFromMesh = true; // Try to read Pointing pose from the source model mesh = rootNode.getObjectByName(this.mapping.pointingPoseMeshName); if (mesh) { var parent = rootNode.parent; // We need to read pose transforms accumulated from the root of the glTF, not the scene. if (parent) { rootNode.parent = null; rootNode.updateMatrixWorld(true); rootNode.parent = parent; } mesh.getWorldPosition(this.rayOrigin.origin); mesh.getWorldQuaternion(quaternion); this.rayOrigin.direction.applyQuaternion(quaternion); // Recalculate the world matrices now that the rootNode is re-attached to the parent. if (parent) { rootNode.updateMatrixWorld(true); } } else { debug('Mesh does not contain pointing origin data, defaulting to none.'); } // Emit event stating that our pointing ray is now accurate. this.modelReady(); }; })(), 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); } }, 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); } } emitIfAxesChanged(this, this.mapping.axes, evt); }, setModelVisibility: function (visible) { var model = this.el.getObject3D('mesh'); visible = visible !== undefined ? visible : this.modelVisible; this.modelVisible = visible; if (!model) { return; } model.visible = visible; } });