├── .gitignore ├── LICENSE ├── README.md ├── browser.js ├── dist ├── aframe-no-click-look-controls.js └── aframe-no-click-look-controls.min.js ├── index.js ├── package.json └── tests ├── __init.test.js ├── helpers.js ├── index.test.js └── karma.conf.js /.gitignore: -------------------------------------------------------------------------------- 1 | .sw[ponm] 2 | examples/node_modules/ 3 | gh-pages 4 | node_modules/ 5 | build.js 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Alex Kass 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #BROKEN DUE TO NEW VERSION OF A-FRAME! 2 | 3 | # A-Frame `no-click-look-controls` Component 4 | 5 | ##Overview 6 | Intuitive camera controls for desktop 3D experiences with [A-Frame](aframe.io). 7 | 8 | ##Features 9 | * :no_entry_sign:<--->:no_entry_sign: Dynamically set maximum yaw and pitch (see options) to control sensitivity and max turn angles. 10 | * :computer: Provides intuitive desktop view controls without requiring mousedown+drag. 11 | * :sunglasses::iphone::100: Includes the core touch and HMD view controls for drop-in replacement of core `look-controls` component. 12 | 13 | ##Demos: 14 | 15 | [Panorama with plenty of space to explore the whole scene without anxiety of moving cursor off the canvas](https://alexrkass.github.io/no-click-example/) 16 | 17 | [User interface with restricted view angles to focus user on content.](https://alexrkass.github.io/aframe-thetarestricted-example) 18 | 19 | ##Usage 20 | ####Script 21 | ```html 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ``` 38 | ####NPM 39 | 40 | Install. 41 | 42 | ``` 43 | $ npm install aframe-no-click-look-controls 44 | ``` 45 | 46 | Register. 47 | 48 | ```javascript 49 | var AFRAME = require('aframe-core'); 50 | var NoClickLookControls = require('aframe-no-click-look-controls'); 51 | AFRAME.registerComponent('no-click-look-controls', NoClickLookControls); 52 | ``` 53 | 54 | Use. 55 | 56 | ##Options 57 | 58 | (units are radians) 59 | 60 | Property | Default | Description 61 | --------------|---------|------------- 62 | maxyaw | 3π | Controls the max y-axis rotation. Actual max viewing angle is twice the parameter, ie 3π is 3π to the right and 3π to the left. 63 | maxpitch | π/2 | Controls the max x-axis rotation. Actual max viewing angle is twice the parameter, ie π/2 is π/2 up and π/2 down. 64 | enabled | true | Enables controls 65 | 66 | ##TODOS (PRs welcome) 67 | 68 | * allow asymmetrical yaw and pitch values rather than forcing symmetrical distances from original camera position 69 | 70 | * add option to slow down camera rotation as the mouse gets closer to the edge of the canvas 71 | 72 | * write tests 73 | 74 | * add a tiny touch of motion smoothing 75 | -------------------------------------------------------------------------------- /browser.js: -------------------------------------------------------------------------------- 1 | // Browser distribution of the A-Frame component. 2 | (function () { 3 | if (typeof AFRAME === 'undefined') { 4 | console.error('Component attempted to register before AFRAME was available.'); 5 | return; 6 | } 7 | 8 | // Register all components here. 9 | var components = { 10 | "no-click-look-controls": require('./index').component 11 | }; 12 | 13 | Object.keys(components).forEach(function (name) { 14 | if (AFRAME.aframeCore) { 15 | AFRAME.aframeCore.registerComponent(name, components[name]); 16 | } else { 17 | AFRAME.registerComponent(name, components[name]); 18 | } 19 | }); 20 | })(); 21 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var registerComponent = require('../aframe-core/src/core/component').registerComponent; 2 | var THREE = require('./../aframe-core/lib/three'); 3 | 4 | // To avoid recalculation at every mouse movement tick 5 | var PI_2 = Math.PI / 2; 6 | 7 | module.exports.component = { 8 | dependencies: ['position', 'rotation'], 9 | schema: { 10 | enabled: { default: true }, 11 | maxpitch: {default: PI_2}, 12 | maxyaw: {default: PI_2 * 6}, 13 | }, 14 | 15 | /** 16 | * Called once when component is attached. Generally for initial setup. 17 | */ 18 | init: function () { 19 | var scene = this.el.sceneEl; 20 | this.setupMouseControls(); 21 | this.setupHMDControls(); 22 | this.attachEventListeners(); 23 | scene.addBehavior(this); 24 | this.previousPosition = new THREE.Vector3(); 25 | this.deltaPosition = new THREE.Vector3(); 26 | }, 27 | 28 | setupMouseControls: function () { 29 | this.canvasEl = document.querySelector('a-scene').canvas; 30 | // The canvas where the scene is painted 31 | this.hovering = false; 32 | this.pitchObject = new THREE.Object3D(); 33 | this.yawObject = new THREE.Object3D(); 34 | this.yawObject.position.y = 10; 35 | this.yawObject.add(this.pitchObject); 36 | }, 37 | 38 | setupHMDControls: function () { 39 | this.dolly = new THREE.Object3D(); 40 | this.euler = new THREE.Euler(); 41 | this.controls = new THREE.VRControls(this.dolly); 42 | this.zeroQuaternion = new THREE.Quaternion(); 43 | }, 44 | 45 | attachEventListeners: function () { 46 | var canvasEl = document.querySelector('a-scene').canvas; 47 | 48 | // Mouse Events 49 | canvasEl.addEventListener('mousemove', this.onMouseMove.bind(this), true); 50 | canvasEl.addEventListener('mouseout', this.onMouseOut.bind(this), true); 51 | canvasEl.addEventListener('mouseover', this.onMouseOver.bind(this), true); 52 | // Touch events 53 | canvasEl.addEventListener('touchstart', this.onTouchStart.bind(this)); 54 | canvasEl.addEventListener('touchmove', this.onTouchMove.bind(this)); 55 | canvasEl.addEventListener('touchend', this.onTouchEnd.bind(this)); 56 | }, 57 | 58 | update: function () { 59 | if (!this.data.enabled) { return; } 60 | this.controls.update(); 61 | this.updateOrientation(); 62 | this.updatePosition(); 63 | }, 64 | 65 | updateOrientation: (function () { 66 | var hmdEuler = new THREE.Euler(); 67 | hmdEuler.order = 'YXZ'; 68 | return function () { 69 | var pitchObject = this.pitchObject; 70 | var yawObject = this.yawObject; 71 | var hmdQuaternion = this.calculateHMDQuaternion(); 72 | hmdEuler.setFromQuaternion(hmdQuaternion); 73 | this.el.setAttribute('rotation', { 74 | x: THREE.Math.radToDeg(hmdEuler.x) + THREE.Math.radToDeg(pitchObject.rotation.x), 75 | y: THREE.Math.radToDeg(hmdEuler.y) + THREE.Math.radToDeg(yawObject.rotation.y), 76 | z: THREE.Math.radToDeg(hmdEuler.z) 77 | }); 78 | }; 79 | })(), 80 | 81 | calculateHMDQuaternion: (function () { 82 | var hmdQuaternion = new THREE.Quaternion(); 83 | return function () { 84 | var dolly = this.dolly; 85 | if (!this.zeroed && !dolly.quaternion.equals(this.zeroQuaternion)) { 86 | this.zeroOrientation(); 87 | this.zeroed = true; 88 | } 89 | hmdQuaternion.copy(this.zeroQuaternion).multiply(dolly.quaternion); 90 | return hmdQuaternion; 91 | }; 92 | })(), 93 | 94 | updatePosition: (function () { 95 | var position = new THREE.Vector3(); 96 | var quaternion = new THREE.Quaternion(); 97 | var scale = new THREE.Vector3(); 98 | return function () { 99 | var el = this.el; 100 | var deltaPosition = this.calculateDeltaPosition(); 101 | var currentPosition = el.getComputedAttribute('position'); 102 | this.el.object3D.matrixWorld.decompose(position, quaternion, scale); 103 | deltaPosition.applyQuaternion(quaternion); 104 | el.setAttribute('position', { 105 | x: currentPosition.x + deltaPosition.x, 106 | y: currentPosition.y + deltaPosition.y, 107 | z: currentPosition.z + deltaPosition.z 108 | }); 109 | }; 110 | })(), 111 | 112 | calculateDeltaPosition: function () { 113 | var dolly = this.dolly; 114 | var deltaPosition = this.deltaPosition; 115 | var previousPosition = this.previousPosition; 116 | deltaPosition.copy(dolly.position); 117 | deltaPosition.sub(previousPosition); 118 | previousPosition.copy(dolly.position); 119 | return deltaPosition; 120 | }, 121 | 122 | updateHMDQuaternion: (function () { 123 | var hmdQuaternion = new THREE.Quaternion(); 124 | return function () { 125 | var dolly = this.dolly; 126 | this.controls.update(); 127 | if (!this.zeroed && !dolly.quaternion.equals(this.zeroQuaternion)) { 128 | this.zeroOrientation(); 129 | this.zeroed = true; 130 | } 131 | hmdQuaternion.copy(this.zeroQuaternion).multiply(dolly.quaternion); 132 | return hmdQuaternion; 133 | }; 134 | })(), 135 | 136 | zeroOrientation: function () { 137 | var euler = new THREE.Euler(); 138 | euler.setFromQuaternion(this.dolly.quaternion.clone().inverse()); 139 | // Cancel out roll and pitch. We want to only reset yaw 140 | euler.z = 0; 141 | euler.x = 0; 142 | this.zeroQuaternion.setFromEuler(euler); 143 | }, 144 | 145 | getMousePosition: function(event, canvasEl) { 146 | 147 | var rect = canvasEl.getBoundingClientRect(); 148 | 149 | // Returns a value from -1 to 1 for X and Y representing the percentage of the max-yaw and max-pitch from the center of the canvas 150 | // -1 is far left or top, 1 is far right or bottom 151 | return {x: -2*(.5 - (event.clientX - rect.left)/rect.width), y: -2*(.5 - (event.clientY - rect.top)/rect.height)}; 152 | }, 153 | 154 | onMouseMove: function (event) { 155 | var pos = this.getMousePosition(event, this.canvasEl); 156 | var x = pos.x; 157 | var y = pos.y; 158 | 159 | var pitchObject = this.pitchObject; 160 | var yawObject = this.yawObject; 161 | 162 | if (!this.hovering || !this.data.enabled) { return; } 163 | yawObject.rotation.y = this.data.maxyaw * -x; 164 | pitchObject.rotation.x = this.data.maxpitch * -y; 165 | }, 166 | 167 | onMouseOver: function (event) { 168 | this.hovering = true; 169 | }, 170 | 171 | onMouseOut: function (event) { 172 | this.hovering = false; 173 | }, 174 | 175 | onTouchStart: function (e) { 176 | if (e.touches.length !== 1) { return; } 177 | this.touchStart = { 178 | x: e.touches[0].pageX, 179 | y: e.touches[0].pageY 180 | }; 181 | this.touchStarted = true; 182 | }, 183 | 184 | onTouchMove: function (e) { 185 | var deltaY; 186 | var yawObject = this.yawObject; 187 | if (!this.touchStarted) { return; } 188 | deltaY = 2 * Math.PI * (e.touches[0].pageX - this.touchStart.x) / this.canvasEl.clientWidth; 189 | // Limits touch orientaion to to yaw (y axis) 190 | yawObject.rotation.y -= deltaY * 0.5; 191 | this.touchStart = { 192 | x: e.touches[0].pageX, 193 | y: e.touches[0].pageY 194 | }; 195 | }, 196 | 197 | onTouchEnd: function () { 198 | this.touchStarted = false; 199 | }, 200 | /** 201 | * Called when a component is removed (e.g., via removeAttribute). 202 | * Generally undoes all modifications to the entity. 203 | */ 204 | remove: function () { } 205 | }; 206 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aframe-no-click-look-controls", 3 | "version": "1.0.4", 4 | "description": "Intuitive controls for desktop A-frame experiences", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "browserify examples/main.js -o examples/build.js", 8 | "dev": "budo examples/main.js:build.js --dir examples --port 8000 --live --open", 9 | "dist": "browserify browser.js -o dist/aframe-no-click-look-controls.js && browserify browser.js | uglifyjs > dist/aframe-no-click-look-controls.min.js", 10 | "postpublish": "npm run dist", 11 | "preghpages": "npm run build && rm -rf gh-pages && cp -r examples gh-pages", 12 | "ghpages": "npm run preghpages && ghpages -p gh-pages", 13 | "test": "karma start ./tests/karma.conf.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/alexrkass/aframe-no-click-look-controls.git" 18 | }, 19 | "keywords": [ 20 | "aframe", 21 | "aframe-component", 22 | "camera", 23 | "no-click", 24 | "aframe-vr", 25 | "vr", 26 | "aframe-layout", 27 | "mozvr", 28 | "webvr" 29 | ], 30 | "author": "Alex Kass ", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/alexrkass/aframe-no-click-look-controls/issues" 34 | }, 35 | "homepage": "https://github.com/alexrkass/aframe-no-click-look-controls#readme", 36 | "devDependencies": { 37 | "aframe-core": "^0.1.0", 38 | "browserify": "^12.0.1", 39 | "browserify-css": "^0.8.3", 40 | "budo": "^7.1.0", 41 | "chai": "^3.4.1", 42 | "chai-shallow-deep-equal": "^1.3.0", 43 | "ghpages": "0.0.3", 44 | "karma": "^0.13.15", 45 | "karma-browserify": "^4.4.2", 46 | "karma-chai-shallow-deep-equal": "0.0.4", 47 | "karma-firefox-launcher": "^0.1.7", 48 | "karma-mocha": "^0.2.1", 49 | "karma-mocha-reporter": "^1.1.3", 50 | "karma-sinon-chai": "^1.1.0", 51 | "mocha": "^2.3.4", 52 | "uglify-js": "^2.6.0", 53 | "webpack": "^1.12.9" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/__init.test.js: -------------------------------------------------------------------------------- 1 | /* global sinon, setup, teardown */ 2 | 3 | /** 4 | * __init.test.js is run before every test case. 5 | */ 6 | window.debug = true; 7 | 8 | var AScene = require('aframe-core').AScene; 9 | 10 | beforeEach(function () { 11 | this.sinon = sinon.sandbox.create(); 12 | // Stub to not create a WebGL context since Travis CI runs headless. 13 | this.sinon.stub(AScene.prototype, 'attachedCallback'); 14 | }); 15 | 16 | afterEach(function () { 17 | // Clean up any attached elements. 18 | ['canvas', 'a-assets', 'a-scene'].forEach(function (tagName) { 19 | var els = document.querySelectorAll(tagName); 20 | for (var i = 0; i < els.length; i++) { 21 | els[i].parentNode.removeChild(els[i]); 22 | } 23 | }); 24 | AScene.scene = null; 25 | 26 | this.sinon.restore(); 27 | }); 28 | -------------------------------------------------------------------------------- /tests/helpers.js: -------------------------------------------------------------------------------- 1 | /* global suite */ 2 | 3 | /** 4 | * Helper method to create a scene, create an entity, add entity to scene, 5 | * add scene to document. 6 | * 7 | * @returns {object} An `` element. 8 | */ 9 | module.exports.entityFactory = function () { 10 | var scene = document.createElement('a-scene'); 11 | var entity = document.createElement('a-entity'); 12 | scene.appendChild(entity); 13 | document.body.appendChild(scene); 14 | return entity; 15 | }; 16 | 17 | /** 18 | * Creates and attaches a mixin element (and an `` element if necessary). 19 | * 20 | * @param {string} id - ID of mixin. 21 | * @param {object} obj - Map of component names to attribute values. 22 | * @returns {object} An attached `` element. 23 | */ 24 | module.exports.mixinFactory = function (id, obj) { 25 | var mixinEl = document.createElement('a-mixin'); 26 | mixinEl.setAttribute('id', id); 27 | Object.keys(obj).forEach(function (componentName) { 28 | mixinEl.setAttribute(componentName, obj[componentName]); 29 | }); 30 | 31 | var assetsEl = document.querySelector('a-assets'); 32 | if (!assetsEl) { 33 | assetsEl = document.createElement('a-assets'); 34 | document.body.appendChild(assetsEl); 35 | } 36 | assetsEl.appendChild(mixinEl); 37 | 38 | return mixinEl; 39 | }; 40 | 41 | /** 42 | * Test that is only run locally and is skipped on CI. 43 | */ 44 | module.exports.getSkipCISuite = function () { 45 | if (window.__env__.TEST_ENV === 'ci') { 46 | return suite.skip; 47 | } else { 48 | return suite; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | var Aframe = require('aframe-core'); 2 | var noClickLookControls = require('../index.js').component; 3 | var entityFactory = require('./helpers').entityFactory; 4 | 5 | Aframe.registerComponent('no-click-look-controls', noClickLookControls); 6 | 7 | describe('no-click-look-controls', function () { 8 | beforeEach(function (done) { 9 | this.el = entityFactory(); 10 | this.el.addEventListener('loaded', function () { 11 | done(); 12 | }); 13 | }); 14 | 15 | describe('no-click-look-controls', function () { 16 | it('is good', function () { 17 | assert.equal(1, 1); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /tests/karma.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = function (config) { 3 | config.set({ 4 | basePath: '../', 5 | browserify: { 6 | paths: ['./'] 7 | }, 8 | browsers: ['firefox_latest'], 9 | customLaunchers: { 10 | firefox_latest: { 11 | base: 'FirefoxNightly', 12 | prefs: { /* empty */ } 13 | } 14 | }, 15 | client: { 16 | captureConsole: true, 17 | mocha: {ui: 'bdd'} 18 | }, 19 | envPreprocessor: [ 20 | 'TEST_ENV' 21 | ], 22 | files: [ 23 | 'tests/**/*.test.js', 24 | ], 25 | frameworks: ['mocha', 'sinon-chai', 'chai-shallow-deep-equal', 'browserify'], 26 | preprocessors: { 27 | 'tests/**/*.js': ['browserify'] 28 | }, 29 | reporters: ['mocha'] 30 | }); 31 | }; 32 | --------------------------------------------------------------------------------