├── .babelrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── dist ├── aframe-physics-extras.js └── aframe-physics-extras.min.js ├── examples ├── assets │ ├── basic.png │ ├── collisionresponse.png │ ├── examples.css │ └── textures │ │ ├── blue.png │ │ ├── green.png │ │ └── red.png ├── basic │ ├── demo-recording.json │ └── index.html ├── body_merger │ └── index.html ├── build.js ├── collision_response │ ├── demo-recording.json │ └── index.html ├── index.html └── main.js ├── index.js ├── machinima_tests ├── __init.test.js ├── assets │ ├── blue.png │ ├── green.png │ └── red.png ├── karma.conf.js ├── main.js ├── recordings │ └── physics-extras.json ├── scenes │ ├── index.html │ ├── scene.html │ └── static.html └── tests │ └── component.test.js ├── package.json ├── readme_files └── physics.gif ├── src ├── body-merger.js ├── physics-collider.js ├── physics-collision-filter.js └── physics-sleepy.js ├── tests ├── __init.test.js ├── components │ ├── physics-collider.test.js │ ├── physics-collision-filter.test.js │ └── physics-sleepy.test.js ├── helpers.js ├── karma.conf.js └── testDependencies.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["env", { 3 | "targets": { 4 | "browsers": [ 5 | "last 1 Samsung versions", "last 2 Firefox versions", 6 | "last 2 Chrome versions", "last 2 FirefoxAndroid versions", 7 | "last 2 iOS versions" 8 | ] 9 | } 10 | }]], 11 | "env": { 12 | "production": { 13 | "presets": ["minify"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | firefox/ 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | addons: 3 | firefox: 'latest' 4 | node_js: 5 | - '6.9.2' 6 | 7 | install: 8 | - npm install 9 | - ./node_modules/.bin/mozilla-download ./firefox/ --product firefox --branch mozilla-central 10 | - export FIREFOX_NIGHTLY_BIN="./firefox/firefox/firefox-bin" 11 | 12 | before_script: 13 | - export DISPLAY=:99.0 14 | - sh -e /etc/init.d/xvfb start 15 | 16 | script: 17 | - $CI_ACTION 18 | 19 | env: 20 | global: 21 | - TEST_SUITE=unit 22 | - CXX=g++-4.8 23 | matrix: 24 | - CI_ACTION="npm run test:ci" 25 | - CI_ACTION="npm run build" 26 | - CI_ACTION="npm run lint" 27 | 28 | cache: 29 | directories: 30 | - node_modules 31 | 32 | addons: 33 | apt: 34 | sources: 35 | - ubuntu-toolchain-r-test 36 | packages: 37 | - g++-4.8 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Will Murphy 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A-Frame Physics Extras 2 | [![npm Dowloads](https://img.shields.io/npm/dt/aframe-physics-extras.svg?style=flat-square)](https://www.npmjs.com/package/aframe-physics-extras) 3 | [![npm Version](http://img.shields.io/npm/v/aframe-physics-extras.svg?style=flat-square)](https://www.npmjs.com/package/aframe-physics-extras) 4 | [![Build Status](https://travis-ci.org/wmurphyrd/aframe-physics-extras.svg?branch=master)](https://travis-ci.org/wmurphyrd/aframe-physics-extras) 5 | 6 | Add-on components for the 7 | [`aframe-physics-system`](https://github.com/donmccurdy/aframe-physics-system) 8 | to add additional collision detection and behavior control options. 9 | 10 | ![aframe-physics-extras in action](./readme_files/physics.gif) 11 | 12 | * [physics-collider](#physics-collider) 13 | * [collision-filter](#collision-filter) 14 | * [sleepy](#sleepy) 15 | 16 | ## physics-collider 17 | 18 | A collision detection component powered by the physics simulation with low 19 | overhead and precise collision zones. This is intended to be placed on 20 | tracked controller entities to monitor collisions and report them to 21 | a gesture interpretation component such as 22 | [super-hands](https://github.com/wmurphyrd/aframe-super-hands-component). 23 | 24 | ### API 25 | 26 | | Property | Description | Default Value | 27 | | -------- | ----------- | ------------- | 28 | | ignoreSleep | Wake sleeping bodies on collision? | `true` | 29 | 30 | `physics-collider` can also report collisions with static bodies when 31 | `ignoreSleep` is `true`. This can be useful to create collision detection zones 32 | for interactivity with things other than dynamic bodies. 33 | 34 | ### Events 35 | 36 | | Type | Description | Detail object | 37 | | --- | --- | --- | 38 | | collisions | Emitted each tick if there are changes to the collision list | `els`: array of new collisions. `cleardEls`: array of collisions which have ended. | 39 | 40 | ## collision-filter 41 | 42 | Control which physics bodies interact with each other or ignore each other. 43 | This can improve physics system performance by skipping unnecessary 44 | collision checks. It also controls which entities can be interacted with 45 | via `physics-collider` 46 | 47 | ### API 48 | 49 | | Property | Description | Default Value | 50 | | -------- | ----------- | ------------- | 51 | | group | Collision group this entity belongs to | `'default'` | 52 | | collidesWith | Array of collision groups this entity will interact with | `'default'` | 53 | | collisionForces | Should other bodies react to collisions with this body? | `true` | 54 | 55 | `collisionForces` controls whether collisions with this body generate any 56 | forces. Setting this to `false` allows for collisions to be registered and 57 | tracked without causing any corresponding movement. This is useful for 58 | your controller entities with `physics-collider` because it is difficult 59 | to pick things up if they are constantly bumped away when your hand gets close. 60 | This can be toggles through events with a controller button press 61 | if you want to be able to bump other 62 | objects sometimes and reach inside to pick them up other times. 63 | [There is an example of this on the examples page](#examples). 64 | 65 | Turning off `collisionForces` can also be useful 66 | for setting static bodies as collision zones to detect the presence 67 | of other entities without disturbing them. 68 | 69 | ## sleepy 70 | 71 | Make entities settle down and be still after physics collisions. Very useful 72 | for zero-gravity user interfaces to keep entities from floating away. Also 73 | can help performance as sleeping bodies are handled efficiently by the physics 74 | simulation. 75 | 76 | ### API 77 | 78 | | Property | Description | Default Value | 79 | | -------- | ----------- | ------------- | 80 | | allowSleep | Enable sleep for this body | `true` | 81 | | speedLimit | Maximum velocity for sleep to initiate | `0.25` | 82 | | delay | Time interval to check for sleep initiation (seconds) | `0.25` | 83 | | linearDamping | Deceleration of liner forces on the entity (0 to 1) | `0.99` | 84 | | angularDamping | Deceleration of angular forces on the entity (0 to 1) | `0.99` | 85 | | holdState | Entity state in which sleep is suspended | `'grabbed'` | 86 | 87 | Adding `sleepy` to any body will activate sleep for the entire physics system 88 | and will affect other bodies because the cannon defaults for all bodies 89 | are to allow sleep with a speed limit of 0.1 and delay of 1 second. You can 90 | add `sleepy="allowSleep: false; linearDamping: 0.01; angularDamping: 0.01"` 91 | to restore default behavior to an entity if needed. 92 | Sleeping bodies will ignore static bodies 93 | (hence why `physics-collider` has an `ignoreSleep` setting) until they 94 | are woken by a dynamic or kinematic body. Sleep will break constraints, 95 | so the `holdState` property allows you to suspend sleep during interactions 96 | such as grabbing/carrying the entity. 97 | 98 | ## Examples 99 | 100 | [View the examples page](http://wmurphyrd.github.io/aframe-physics-extras/examples/) to see `aframe-physics-extras` in action. 101 | 102 | ## Installation 103 | 104 | ### Browser 105 | 106 | Install and use by directly including the [browser files](dist): 107 | 108 | [![Remix on Glitch](https://cdn.glitch.com/2703baf2-b643-4da7-ab91-7ee2a2d00b5b%2Fremix-button.svg)](https://glitch.com/edit/#!/remix/blue-animal) 109 | 110 | ```html 111 | 112 | 113 | 114 | My A-Frame Scene 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 134 | 135 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 145 | 146 | 149 | 151 | 152 | 153 | 156 | 157 | 158 | 159 | ``` 160 | 161 | ### npm 162 | 163 | Install via npm: 164 | 165 | ```bash 166 | npm install 167 | ``` 168 | 169 | Then require and use. 170 | 171 | ```js 172 | require('aframe'); 173 | require('aframe-physics-system') 174 | require('aframe-physics-extras'); 175 | ``` 176 | -------------------------------------------------------------------------------- /dist/aframe-physics-extras.js: -------------------------------------------------------------------------------- 1 | (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o { 23 | if (evt.target === this.el) { 24 | this.el.removeEventListener('body-loaded', doMerge); 25 | this.merge(); 26 | } 27 | }; 28 | if (this.el.body) { 29 | this.merge(); 30 | } else { 31 | this.el.addEventListener('body-loaded', doMerge); 32 | } 33 | }, 34 | merge: function () { 35 | const body = this.el.body; 36 | const tmpMat = new THREE.Matrix4(); 37 | const tmpQuat = new THREE.Quaternion(); 38 | const tmpPos = new THREE.Vector3(); 39 | const tmpScale = new THREE.Vector3(1, 1, 1); // todo: apply worldScale 40 | const offset = new CANNON.Vec3(); 41 | const orientation = new CANNON.Quaternion(); 42 | for (let child of this.el.childNodes) { 43 | if (!child.body || !child.getAttribute(this.data)) { 44 | continue; 45 | } 46 | child.object3D.updateMatrix(); 47 | while (child.body.shapes.length) { 48 | tmpPos.copy(child.body.shapeOffsets.pop()); 49 | tmpQuat.copy(child.body.shapeOrientations.pop()); 50 | tmpMat.compose(tmpPos, tmpQuat, tmpScale); 51 | tmpMat.multiply(child.object3D.matrix); 52 | tmpMat.decompose(tmpPos, tmpQuat, tmpScale); 53 | offset.copy(tmpPos); 54 | orientation.copy(tmpQuat); 55 | body.addShape(child.body.shapes.pop(), offset, orientation); 56 | } 57 | child.removeAttribute(this.data); 58 | } 59 | } 60 | }); 61 | 62 | },{}],3:[function(require,module,exports){ 63 | 'use strict'; 64 | 65 | /* global AFRAME */ 66 | AFRAME.registerComponent('physics-collider', { 67 | schema: { 68 | ignoreSleep: { default: true } 69 | }, 70 | init: function () { 71 | this.collisions = new Set(); 72 | this.currentCollisions = new Set(); 73 | this.newCollisions = []; 74 | this.clearedCollisions = []; 75 | this.collisionEventDetails = { 76 | els: this.newCollisions, 77 | clearedEls: this.clearedCollisions 78 | }; 79 | }, 80 | update: function () { 81 | if (this.el.body) { 82 | this.updateBody(); 83 | } else { 84 | this.el.addEventListener('body-loaded', this.updateBody.bind(this), { once: true }); 85 | } 86 | }, 87 | tick: function () { 88 | const uppperMask = 0xFFFF0000; 89 | const lowerMask = 0x0000FFFF; 90 | return function () { 91 | if (!this.el.body) return; 92 | const currentCollisions = this.currentCollisions; 93 | const thisBodyId = this.el.body.id; 94 | const worldCollisions = this.el.body.world.bodyOverlapKeeper.current; 95 | const worldBodyMap = this.el.body.world.idToBodyMap; 96 | const collisions = this.collisions; 97 | const newCollisions = this.newCollisions; 98 | const clearedCollisions = this.clearedCollisions; 99 | let i = 0; 100 | let upperId = (worldCollisions[i] & uppperMask) >> 16; 101 | let target; 102 | newCollisions.length = clearedCollisions.length = 0; 103 | currentCollisions.clear(); 104 | while (i < worldCollisions.length && upperId < thisBodyId) { 105 | if (worldBodyMap[upperId]) { 106 | target = worldBodyMap[upperId].el; 107 | if ((worldCollisions[i] & lowerMask) === thisBodyId) { 108 | currentCollisions.add(target); 109 | if (!collisions.has(target)) { 110 | newCollisions.push(target); 111 | } 112 | } 113 | } 114 | upperId = (worldCollisions[++i] & uppperMask) >> 16; 115 | } 116 | while (i < worldCollisions.length && upperId === thisBodyId) { 117 | if (worldBodyMap[worldCollisions[i] & lowerMask]) { 118 | target = worldBodyMap[worldCollisions[i] & lowerMask].el; 119 | currentCollisions.add(target); 120 | if (!collisions.has(target)) { 121 | newCollisions.push(target); 122 | } 123 | } 124 | upperId = (worldCollisions[++i] & uppperMask) >> 16; 125 | } 126 | 127 | for (let col of collisions) { 128 | if (!currentCollisions.has(col)) { 129 | clearedCollisions.push(col); 130 | collisions.delete(col); 131 | } 132 | } 133 | for (let col of newCollisions) { 134 | collisions.add(col); 135 | } 136 | if (newCollisions.length || clearedCollisions.length) { 137 | this.el.emit('collisions', this.collisionEventDetails); 138 | } 139 | }; 140 | }(), 141 | remove: function () { 142 | if (this.originalSleepConfig) { 143 | AFRAME.utils.extend(this.el.body, this.originalSleepConfig); 144 | } 145 | }, 146 | updateBody: function (evt) { 147 | // ignore bubbled 'body-loaded' events 148 | if (evt !== undefined && evt.target !== this.el) { 149 | return; 150 | } 151 | if (this.data.ignoreSleep) { 152 | // ensure sleep doesn't disable collision detection 153 | this.el.body.allowSleep = false; 154 | /* naiveBroadphase ignores collisions between sleeping & static bodies */ 155 | this.el.body.type = window.CANNON.Body.KINEMATIC; 156 | // Kinematics must have velocity >= their sleep limit to wake others 157 | this.el.body.sleepSpeedLimit = 0; 158 | } else if (this.originalSleepConfig === undefined) { 159 | this.originalSleepConfig = { 160 | allowSleep: this.el.body.allowSleep, 161 | sleepSpeedLimit: this.el.body.sleepSpeedLimit, 162 | type: this.el.body.type 163 | }; 164 | } else { 165 | // restore original settings 166 | AFRAME.utils.extend(this.el.body, this.originalSleepConfig); 167 | } 168 | } 169 | }); 170 | 171 | },{}],4:[function(require,module,exports){ 172 | 'use strict'; 173 | 174 | /* global AFRAME */ 175 | AFRAME.registerComponent('collision-filter', { 176 | schema: { 177 | group: { default: 'default' }, 178 | collidesWith: { default: ['default'] }, 179 | collisionForces: { default: true } 180 | }, 181 | init: function () { 182 | this.updateBodyBound = this.updateBody.bind(this); 183 | this.system.registerMe(this); 184 | this.el.addEventListener('body-loaded', this.updateBodyBound); 185 | }, 186 | update: function () { 187 | // register any new groups 188 | this.system.registerMe(this); 189 | if (this.el.body) { 190 | this.updateBody(); 191 | } 192 | }, 193 | remove: function () { 194 | this.el.removeEventListener('body-loaded', this.updateBodyBound); 195 | }, 196 | updateBody: function (evt) { 197 | // ignore bubbled 'body-loaded' events 198 | if (evt !== undefined && evt.target !== this.el) { 199 | return; 200 | } 201 | this.el.body.collisionFilterMask = this.system.getFilterCode(this.data.collidesWith); 202 | this.el.body.collisionFilterGroup = this.system.getFilterCode(this.data.group); 203 | this.el.body.collisionResponse = this.data.collisionForces; 204 | } 205 | }); 206 | 207 | AFRAME.registerSystem('collision-filter', { 208 | schema: { 209 | collisionGroups: { default: ['default'] } 210 | }, 211 | dependencies: ['physics'], 212 | init: function () { 213 | this.maxGroups = Math.log2(Number.MAX_SAFE_INTEGER); 214 | }, 215 | registerMe: function (comp) { 216 | // add any unknown groups to the master list 217 | const newGroups = [comp.data.group, ...comp.data.collidesWith].filter(group => this.data.collisionGroups.indexOf(group) === -1); 218 | this.data.collisionGroups.push(...newGroups); 219 | if (this.data.collisionGroups.length > this.maxGroups) { 220 | throw new Error('Too many collision groups'); 221 | } 222 | }, 223 | getFilterCode: function (elGroups) { 224 | let code = 0; 225 | if (!Array.isArray(elGroups)) { 226 | elGroups = [elGroups]; 227 | } 228 | // each group corresponds to a bit which is turned on when matched 229 | // floor negates any unmatched groups (2^-1 = 0.5) 230 | elGroups.forEach(group => { 231 | code += Math.floor(Math.pow(2, this.data.collisionGroups.indexOf(group))); 232 | }); 233 | return code; 234 | } 235 | }); 236 | 237 | },{}],5:[function(require,module,exports){ 238 | 'use strict'; 239 | 240 | // Make dynamic bodies idle when not grabbed 241 | /* global AFRAME */ 242 | AFRAME.registerComponent('sleepy', { 243 | schema: { 244 | allowSleep: { default: true }, 245 | speedLimit: { default: 0.25, type: 'number' }, 246 | delay: { default: 0.25, type: 'number' }, 247 | linearDamping: { default: 0.99, type: 'number' }, 248 | angularDamping: { default: 0.99, type: 'number' }, 249 | holdState: { default: 'grabbed' } 250 | }, 251 | init: function () { 252 | this.updateBodyBound = this.updateBody.bind(this); 253 | this.holdStateBound = this.holdState.bind(this); 254 | this.resumeStateBound = this.resumeState.bind(this); 255 | 256 | this.el.addEventListener('body-loaded', this.updateBodyBound); 257 | }, 258 | update: function () { 259 | if (this.el.body) { 260 | this.updateBody(); 261 | } 262 | }, 263 | remove: function () { 264 | this.el.removeEventListener('body-loaded', this.updateBodyBound); 265 | this.el.removeEventListener('stateadded', this.holdStateBound); 266 | this.el.removeEventListener('stateremoved', this.resumeStateBound); 267 | }, 268 | updateBody: function (evt) { 269 | // ignore bubbled 'body-loaded' events 270 | if (evt !== undefined && evt.target !== this.el) { 271 | return; 272 | } 273 | if (this.data.allowSleep) { 274 | // only "local" driver compatable 275 | try { 276 | this.el.body.world.allowSleep = true; 277 | } catch (err) { 278 | console.error('Unable to activate sleep in physics.' + '`sleepy` requires "local" physics driver'); 279 | } 280 | } 281 | this.el.body.allowSleep = this.data.allowSleep; 282 | this.el.body.sleepSpeedLimit = this.data.speedLimit; 283 | this.el.body.sleepTimeLimit = this.data.delay; 284 | this.el.body.linearDamping = this.data.linearDamping; 285 | this.el.body.angularDamping = this.data.angularDamping; 286 | if (this.data.allowSleep) { 287 | this.el.addEventListener('stateadded', this.holdStateBound); 288 | this.el.addEventListener('stateremoved', this.resumeStateBound); 289 | } else { 290 | this.el.removeEventListener('stateadded', this.holdStateBound); 291 | this.el.removeEventListener('stateremoved', this.resumeStateBound); 292 | } 293 | }, 294 | // disble the sleeping during interactions because sleep will break constraints 295 | holdState: function (evt) { 296 | let state = this.data.holdState; 297 | // api change in A-Frame v0.8.0 298 | if (evt.detail === state || evt.detail.state === state) { 299 | this.el.body.allowSleep = false; 300 | } 301 | }, 302 | resumeState: function (evt) { 303 | let state = this.data.holdState; 304 | if (evt.detail === state || evt.detail.state === state) { 305 | this.el.body.allowSleep = this.data.allowSleep; 306 | } 307 | } 308 | 309 | }); 310 | 311 | },{}]},{},[1]); 312 | -------------------------------------------------------------------------------- /dist/aframe-physics-extras.min.js: -------------------------------------------------------------------------------- 1 | (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o{b.target===this.el&&(this.el.removeEventListener('body-loaded',a),this.merge())};this.el.body?this.merge():this.el.addEventListener('body-loaded',a)},merge:function(){const a=this.el.body,b=new THREE.Matrix4,c=new THREE.Quaternion,d=new THREE.Vector3,e=new THREE.Vector3(1,1,1),f=new CANNON.Vec3,g=new CANNON.Quaternion;for(let h of this.el.childNodes)if(h.body&&h.getAttribute(this.data)){for(h.object3D.updateMatrix();h.body.shapes.length;)d.copy(h.body.shapeOffsets.pop()),c.copy(h.body.shapeOrientations.pop()),b.compose(d,c,e),b.multiply(h.object3D.matrix),b.decompose(d,c,e),f.copy(d),g.copy(c),a.addShape(h.body.shapes.pop(),f,g);h.removeAttribute(this.data)}}}); 6 | 7 | },{}],3:[function(require,module,exports){ 8 | 'use strict';AFRAME.registerComponent('physics-collider',{schema:{ignoreSleep:{default:!0}},init:function(){this.collisions=new Set,this.currentCollisions=new Set,this.newCollisions=[],this.clearedCollisions=[],this.collisionEventDetails={els:this.newCollisions,clearedEls:this.clearedCollisions}},update:function(){this.el.body?this.updateBody():this.el.addEventListener('body-loaded',this.updateBody.bind(this),{once:!0})},tick:function(){const a=4294901760,b=65535;return function(){if(!this.el.body)return;const c=this.currentCollisions,d=this.el.body.id,e=this.el.body.world.bodyOverlapKeeper.current,f=this.el.body.world.idToBodyMap,g=this.collisions,h=this.newCollisions,j=this.clearedCollisions;let k,l=0,i=(e[l]&a)>>16;for(h.length=j.length=0,c.clear();l>16;for(;l>16;for(let a of g)c.has(a)||(j.push(a),g.delete(a));for(let a of h)g.add(a);(h.length||j.length)&&this.el.emit('collisions',this.collisionEventDetails)}}(),remove:function(){this.originalSleepConfig&&AFRAME.utils.extend(this.el.body,this.originalSleepConfig)},updateBody:function(a){void 0!==a&&a.target!==this.el||(this.data.ignoreSleep?(this.el.body.allowSleep=!1,this.el.body.type=window.CANNON.Body.KINEMATIC,this.el.body.sleepSpeedLimit=0):this.originalSleepConfig===void 0?this.originalSleepConfig={allowSleep:this.el.body.allowSleep,sleepSpeedLimit:this.el.body.sleepSpeedLimit,type:this.el.body.type}:AFRAME.utils.extend(this.el.body,this.originalSleepConfig))}}); 9 | 10 | },{}],4:[function(require,module,exports){ 11 | 'use strict';AFRAME.registerComponent('collision-filter',{schema:{group:{default:'default'},collidesWith:{default:['default']},collisionForces:{default:!0}},init:function(){this.updateBodyBound=this.updateBody.bind(this),this.system.registerMe(this),this.el.addEventListener('body-loaded',this.updateBodyBound)},update:function(){this.system.registerMe(this),this.el.body&&this.updateBody()},remove:function(){this.el.removeEventListener('body-loaded',this.updateBodyBound)},updateBody:function(a){void 0!==a&&a.target!==this.el||(this.el.body.collisionFilterMask=this.system.getFilterCode(this.data.collidesWith),this.el.body.collisionFilterGroup=this.system.getFilterCode(this.data.group),this.el.body.collisionResponse=this.data.collisionForces)}}),AFRAME.registerSystem('collision-filter',{schema:{collisionGroups:{default:['default']}},dependencies:['physics'],init:function(){this.maxGroups=Math.log2(Number.MAX_SAFE_INTEGER)},registerMe:function(a){const b=[a.data.group,...a.data.collidesWith].filter((a)=>-1===this.data.collisionGroups.indexOf(a));if(this.data.collisionGroups.push(...b),this.data.collisionGroups.length>this.maxGroups)throw new Error('Too many collision groups')},getFilterCode:function(a){let b=0;return Array.isArray(a)||(a=[a]),a.forEach((a)=>{b+=Math.floor(Math.pow(2,this.data.collisionGroups.indexOf(a)))}),b}}); 12 | 13 | },{}],5:[function(require,module,exports){ 14 | 'use strict';AFRAME.registerComponent('sleepy',{schema:{allowSleep:{default:!0},speedLimit:{default:0.25,type:'number'},delay:{default:0.25,type:'number'},linearDamping:{default:0.99,type:'number'},angularDamping:{default:0.99,type:'number'},holdState:{default:'grabbed'}},init:function(){this.updateBodyBound=this.updateBody.bind(this),this.holdStateBound=this.holdState.bind(this),this.resumeStateBound=this.resumeState.bind(this),this.el.addEventListener('body-loaded',this.updateBodyBound)},update:function(){this.el.body&&this.updateBody()},remove:function(){this.el.removeEventListener('body-loaded',this.updateBodyBound),this.el.removeEventListener('stateadded',this.holdStateBound),this.el.removeEventListener('stateremoved',this.resumeStateBound)},updateBody:function(a){if(void 0===a||a.target===this.el){if(this.data.allowSleep)try{this.el.body.world.allowSleep=!0}catch(a){console.error('Unable to activate sleep in physics.`sleepy` requires "local" physics driver')}this.el.body.allowSleep=this.data.allowSleep,this.el.body.sleepSpeedLimit=this.data.speedLimit,this.el.body.sleepTimeLimit=this.data.delay,this.el.body.linearDamping=this.data.linearDamping,this.el.body.angularDamping=this.data.angularDamping,this.data.allowSleep?(this.el.addEventListener('stateadded',this.holdStateBound),this.el.addEventListener('stateremoved',this.resumeStateBound)):(this.el.removeEventListener('stateadded',this.holdStateBound),this.el.removeEventListener('stateremoved',this.resumeStateBound))}},holdState:function(a){let b=this.data.holdState;(a.detail===b||a.detail.state===b)&&(this.el.body.allowSleep=!1)},resumeState:function(a){let b=this.data.holdState;(a.detail===b||a.detail.state===b)&&(this.el.body.allowSleep=this.data.allowSleep)}}); 15 | 16 | },{}]},{},[1]); 17 | -------------------------------------------------------------------------------- /examples/assets/basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmurphyrd/aframe-physics-extras/47e46cc9177ce4c5aa6cb27ba124e5861928345f/examples/assets/basic.png -------------------------------------------------------------------------------- /examples/assets/collisionresponse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmurphyrd/aframe-physics-extras/47e46cc9177ce4c5aa6cb27ba124e5861928345f/examples/assets/collisionresponse.png -------------------------------------------------------------------------------- /examples/assets/examples.css: -------------------------------------------------------------------------------- 1 | #replayer-button { 2 | position: fixed; 3 | bottom: 10px; 4 | left: 50%; 5 | transform: translate(-50%, 0); 6 | padding: 5px; 7 | z-index: 100; 8 | } 9 | -------------------------------------------------------------------------------- /examples/assets/textures/blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmurphyrd/aframe-physics-extras/47e46cc9177ce4c5aa6cb27ba124e5861928345f/examples/assets/textures/blue.png -------------------------------------------------------------------------------- /examples/assets/textures/green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmurphyrd/aframe-physics-extras/47e46cc9177ce4c5aa6cb27ba124e5861928345f/examples/assets/textures/green.png -------------------------------------------------------------------------------- /examples/assets/textures/red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmurphyrd/aframe-physics-extras/47e46cc9177ce4c5aa6cb27ba124e5861928345f/examples/assets/textures/red.png -------------------------------------------------------------------------------- /examples/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A-Frame Physics Extras Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 44 | 45 | 47 | 48 | 49 | 51 | 52 | 54 | 55 | 56 | 59 | 60 | 63 | 64 | 65 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /examples/body_merger/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A-Frame Physics Extras Example 5 | 6 | 7 | 8 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 30 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /examples/build.js: -------------------------------------------------------------------------------- 1 | (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o { 13 | let c = document.querySelector('[camera]'); 14 | window.setTimeout(function () { 15 | c.setAttribute('position', '0 1.6 2'); 16 | c.setAttribute('rotation', '0 0 0'); 17 | }); 18 | }); 19 | s.setAttribute('avatar-replayer', { 20 | src: './demo-recording.json', 21 | spectatorMode: spectate === undefined ? true : spectate, 22 | spectatorPosition: { x: 0, y: 1.6, z: 2 } 23 | }); 24 | }; 25 | 26 | },{"../index.js":2,"aframe-motion-capture-components":9}],2:[function(require,module,exports){ 27 | 'use strict'; 28 | 29 | /* global AFRAME */ 30 | 31 | if (typeof AFRAME === 'undefined') { 32 | throw new Error('Component attempted to register before AFRAME was available.'); 33 | } 34 | 35 | require('./src/physics-collider.js'); 36 | require('./src/physics-collision-filter.js'); 37 | require('./src/physics-sleepy.js'); 38 | require('./src/body-merger.js'); 39 | 40 | },{"./src/body-merger.js":12,"./src/physics-collider.js":13,"./src/physics-collision-filter.js":14,"./src/physics-sleepy.js":15}],3:[function(require,module,exports){ 41 | /* global THREE, AFRAME */ 42 | var constants = require('../constants'); 43 | var log = AFRAME.utils.debug('aframe-motion-capture:avatar-recorder:info'); 44 | var warn = AFRAME.utils.debug('aframe-motion-capture:avatar-recorder:warn'); 45 | 46 | /** 47 | * Wrapper around individual motion-capture-recorder components for recording camera and 48 | * controllers together. 49 | */ 50 | AFRAME.registerComponent('avatar-recorder', { 51 | schema: { 52 | autoPlay: {default: false}, 53 | autoRecord: {default: false}, 54 | cameraOverride: {type: 'selector'}, 55 | localStorage: {default: true}, 56 | recordingName: {default: constants.DEFAULT_RECORDING_NAME}, 57 | loop: {default: true} 58 | }, 59 | 60 | init: function () { 61 | this.cameraEl = null; 62 | this.isRecording = false; 63 | this.trackedControllerEls = {}; 64 | this.recordingData = null; 65 | 66 | this.onKeyDown = AFRAME.utils.bind(this.onKeyDown, this); 67 | this.tick = AFRAME.utils.throttle(this.throttledTick, 100, this); 68 | }, 69 | 70 | /** 71 | * Poll for tracked controllers. 72 | */ 73 | throttledTick: function () { 74 | var self = this; 75 | var trackedControllerEls = this.el.querySelectorAll('[tracked-controls]'); 76 | this.trackedControllerEls = {}; 77 | trackedControllerEls.forEach(function setupController (trackedControllerEl) { 78 | if (!trackedControllerEl.id) { 79 | warn('Found a tracked controller entity without an ID. ' + 80 | 'Provide an ID or this controller will not be recorded'); 81 | return; 82 | } 83 | trackedControllerEl.setAttribute('motion-capture-recorder', { 84 | autoRecord: false, 85 | visibleStroke: false 86 | }); 87 | self.trackedControllerEls[trackedControllerEl.id] = trackedControllerEl; 88 | if (self.isRecording) { 89 | trackedControllerEl.components['motion-capture-recorder'].startRecording(); 90 | } 91 | }); 92 | }, 93 | 94 | play: function () { 95 | window.addEventListener('keydown', this.onKeyDown); 96 | }, 97 | 98 | pause: function () { 99 | window.removeEventListener('keydown', this.onKeyDown); 100 | }, 101 | 102 | /** 103 | * Keyboard shortcuts. 104 | */ 105 | onKeyDown: function (evt) { 106 | var key = evt.keyCode; 107 | var KEYS = {space: 32}; 108 | switch (key) { 109 | // : Toggle recording. 110 | case KEYS.space: { 111 | this.toggleRecording(); 112 | break; 113 | } 114 | } 115 | }, 116 | 117 | /** 118 | * Start or stop recording. 119 | */ 120 | toggleRecording: function () { 121 | if (this.isRecording) { 122 | this.stopRecording(); 123 | } else { 124 | this.startRecording(); 125 | } 126 | }, 127 | 128 | /** 129 | * Set motion capture recorder on the camera once the camera is ready. 130 | */ 131 | setupCamera: function (doneCb) { 132 | var el = this.el; 133 | var self = this; 134 | 135 | if (this.data.cameraOverride) { 136 | prepareCamera(this.data.cameraOverride); 137 | return; 138 | } 139 | 140 | // Grab camera. 141 | if (el.camera && el.camera.el) { 142 | prepareCamera(el.camera.el); 143 | return; 144 | } 145 | 146 | el.addEventListener('camera-set-active', function setup (evt) { 147 | prepareCamera(evt.detail.cameraEl); 148 | el.removeEventListener('camera-set-active', setup); 149 | }); 150 | 151 | function prepareCamera (cameraEl) { 152 | if (self.cameraEl) { 153 | self.cameraEl.removeAttribute('motion-capture-recorder'); 154 | } 155 | self.cameraEl = cameraEl; 156 | cameraEl.setAttribute('motion-capture-recorder', { 157 | autoRecord: false, 158 | visibleStroke: false 159 | }); 160 | doneCb(cameraEl) 161 | } 162 | }, 163 | 164 | /** 165 | * Start recording camera and tracked controls. 166 | */ 167 | startRecording: function () { 168 | var trackedControllerEls = this.trackedControllerEls; 169 | var self = this; 170 | 171 | if (this.isRecording) { return; } 172 | 173 | log('Starting recording!'); 174 | 175 | if (this.el.components['avatar-replayer']) { 176 | this.el.components['avatar-replayer'].stopReplaying(); 177 | } 178 | 179 | // Get camera. 180 | this.setupCamera(function cameraSetUp () { 181 | self.isRecording = true; 182 | // Record camera. 183 | self.cameraEl.components['motion-capture-recorder'].startRecording(); 184 | // Record tracked controls. 185 | Object.keys(trackedControllerEls).forEach(function startRecordingController (id) { 186 | trackedControllerEls[id].components['motion-capture-recorder'].startRecording(); 187 | }); 188 | }); 189 | }, 190 | 191 | /** 192 | * Tell camera and tracked controls motion-capture-recorder components to stop recording. 193 | * Store recording and replay if autoPlay is on. 194 | */ 195 | stopRecording: function () { 196 | var trackedControllerEls = this.trackedControllerEls; 197 | 198 | if (!this.isRecording) { return; } 199 | 200 | log('Stopped recording.'); 201 | this.isRecording = false; 202 | this.cameraEl.components['motion-capture-recorder'].stopRecording(); 203 | Object.keys(trackedControllerEls).forEach(function (id) { 204 | trackedControllerEls[id].components['motion-capture-recorder'].stopRecording(); 205 | }); 206 | this.recordingData = this.getJSONData(); 207 | this.storeRecording(this.recordingData); 208 | 209 | if (this.data.autoPlay) { 210 | this.replayRecording(); 211 | } 212 | }, 213 | 214 | /** 215 | * Gather the JSON data from the camera and tracked controls motion-capture-recorder 216 | * components. Combine them together, keyed by the (active) `camera` and by the 217 | * tracked controller IDs. 218 | */ 219 | getJSONData: function () { 220 | var data = {}; 221 | var trackedControllerEls = this.trackedControllerEls; 222 | 223 | if (this.isRecording) { return; } 224 | 225 | // Camera. 226 | data.camera = this.cameraEl.components['motion-capture-recorder'].getJSONData(); 227 | 228 | // Tracked controls. 229 | Object.keys(trackedControllerEls).forEach(function getControllerData (id) { 230 | data[id] = trackedControllerEls[id].components['motion-capture-recorder'].getJSONData(); 231 | }); 232 | 233 | return data; 234 | }, 235 | 236 | /** 237 | * Store recording in IndexedDB using recordingdb system. 238 | */ 239 | storeRecording: function (recordingData) { 240 | var data = this.data; 241 | if (!data.localStorage) { return; } 242 | log('Recording stored in localStorage.'); 243 | this.el.systems.recordingdb.addRecording(data.recordingName, recordingData); 244 | } 245 | }); 246 | 247 | },{"../constants":8}],4:[function(require,module,exports){ 248 | /* global THREE, AFRAME */ 249 | var constants = require('../constants'); 250 | 251 | var bind = AFRAME.utils.bind; 252 | var error = AFRAME.utils.debug('aframe-motion-capture:avatar-replayer:error'); 253 | var log = AFRAME.utils.debug('aframe-motion-capture:avatar-replayer:info'); 254 | var warn = AFRAME.utils.debug('aframe-motion-capture:avatar-replayer:warn'); 255 | 256 | var fileLoader = new THREE.FileLoader(); 257 | 258 | AFRAME.registerComponent('avatar-replayer', { 259 | schema: { 260 | autoPlay: {default: true}, 261 | cameraOverride: {type: 'selector'}, 262 | loop: {default: false}, 263 | recordingName: {default: constants.DEFAULT_RECORDING_NAME}, 264 | spectatorMode: {default: false}, 265 | spectatorPosition: {default: {x: 0, y: 1.6, z: 2}, type: 'vec3'}, 266 | src: {default: ''} 267 | }, 268 | 269 | init: function () { 270 | var sceneEl = this.el; 271 | 272 | // Bind methods. 273 | this.onKeyDown = bind(this.onKeyDown, this); 274 | 275 | // Prepare camera. 276 | this.setupCamera = bind(this.setupCamera, this); 277 | if (sceneEl.camera) { 278 | this.setupCamera(); 279 | } else { 280 | sceneEl.addEventListener('camera-set-active', this.setupCamera); 281 | } 282 | 283 | if (this.data.autoPlay) { 284 | this.replayRecordingFromSource(); 285 | } 286 | }, 287 | 288 | update: function (oldData) { 289 | var data = this.data; 290 | var spectatorModeUrlParam; 291 | 292 | spectatorModeUrlParam = 293 | window.location.search.indexOf('spectatormode') !== -1 || 294 | window.location.search.indexOf('spectatorMode') !== -1; 295 | 296 | // Handle toggling spectator mode. Don't run on initialization. Want to activate after 297 | // the player camera is initialized. 298 | if (oldData.spectatorMode !== data.spectatorMode || 299 | spectatorModeUrlParam) { 300 | if (data.spectatorMode || spectatorModeUrlParam) { 301 | this.activateSpectatorCamera(); 302 | } else if (oldData.spectatorMode === true) { 303 | this.deactivateSpectatorCamera(); 304 | } 305 | } 306 | 307 | // Handle `src` changing. 308 | if (data.src && oldData.src !== data.src && data.autoPlay) { 309 | this.replayRecordingFromSource(); 310 | } 311 | }, 312 | 313 | play: function () { 314 | window.addEventListener('keydown', this.onKeyDown); 315 | }, 316 | 317 | pause: function () { 318 | window.removeEventListener('keydown', this.onKeyDown); 319 | }, 320 | 321 | remove: function () { 322 | this.stopReplaying(); 323 | this.cameraEl.removeObject3D('replayerMesh'); 324 | }, 325 | 326 | /** 327 | * Grab a handle to the "original" camera. 328 | * Initialize spectator camera and dummy geometry for original camera. 329 | */ 330 | setupCamera: function () { 331 | var data = this.data; 332 | var sceneEl = this.el; 333 | 334 | if (data.cameraOverride) { 335 | // Specify which camera is the original camera (e.g., used by Inspector). 336 | this.cameraEl = data.cameraOverride; 337 | } else { 338 | // Default camera. 339 | this.cameraEl = sceneEl.camera.el; 340 | // Make sure A-Frame doesn't automatically remove this camera. 341 | this.cameraEl.removeAttribute('data-aframe-default-camera'); 342 | } 343 | this.cameraEl.setAttribute('data-aframe-avatar-replayer-camera', ''); 344 | 345 | sceneEl.removeEventListener('camera-set-active', this.setupCamera); 346 | 347 | this.configureHeadGeometry(); 348 | 349 | // Create spectator camera for either if we are in spectator mode or toggling to it. 350 | this.initSpectatorCamera(); 351 | }, 352 | 353 | /** 354 | * q: Toggle spectator camera. 355 | */ 356 | onKeyDown: function (evt) { 357 | switch (evt.keyCode) { 358 | // q. 359 | case 81: { 360 | this.el.setAttribute('avatar-replayer', 'spectatorMode', !this.data.spectatorMode); 361 | break; 362 | } 363 | } 364 | }, 365 | 366 | /** 367 | * Activate spectator camera, show replayer mesh. 368 | */ 369 | activateSpectatorCamera: function () { 370 | var spectatorCameraEl = this.spectatorCameraEl; 371 | 372 | if (!spectatorCameraEl) { 373 | this.el.addEventListener('spectatorcameracreated', 374 | bind(this.activateSpectatorCamera, this)); 375 | return; 376 | } 377 | 378 | if (!spectatorCameraEl.hasLoaded) { 379 | spectatorCameraEl.addEventListener('loaded', bind(this.activateSpectatorCamera, this)); 380 | return; 381 | } 382 | 383 | log('Activating spectator camera'); 384 | spectatorCameraEl.setAttribute('camera', 'active', true); 385 | this.cameraEl.getObject3D('replayerMesh').visible = true; 386 | }, 387 | 388 | /** 389 | * Deactivate spectator camera (by setting original camera active), hide replayer mesh. 390 | */ 391 | deactivateSpectatorCamera: function () { 392 | log('Deactivating spectator camera'); 393 | this.cameraEl.setAttribute('camera', 'active', true); 394 | this.cameraEl.getObject3D('replayerMesh').visible = false; 395 | }, 396 | 397 | /** 398 | * Create and activate spectator camera if in spectator mode. 399 | */ 400 | initSpectatorCamera: function () { 401 | var data = this.data; 402 | var sceneEl = this.el; 403 | var spectatorCameraEl; 404 | var spectatorCameraRigEl; 405 | 406 | // Developer-defined spectator rig. 407 | if (this.el.querySelector('#spectatorCameraRig')) { 408 | this.spectatorCameraEl = sceneEl.querySelector('#spectatorCameraRig'); 409 | return; 410 | } 411 | 412 | // Create spectator camera rig. 413 | spectatorCameraRigEl = sceneEl.querySelector('#spectatorCameraRig') || 414 | document.createElement('a-entity'); 415 | spectatorCameraRigEl.id = 'spectatorCameraRig'; 416 | spectatorCameraRigEl.setAttribute('position', data.spectatorPosition); 417 | this.spectatorCameraRigEl = spectatorCameraRigEl; 418 | 419 | // Create spectator camera. 420 | spectatorCameraEl = sceneEl.querySelector('#spectatorCamera') || 421 | document.createElement('a-entity'); 422 | spectatorCameraEl.id = 'spectatorCamera'; 423 | spectatorCameraEl.setAttribute('camera', {active: data.spectatorMode, userHeight: 0}); 424 | spectatorCameraEl.setAttribute('look-controls', ''); 425 | spectatorCameraEl.setAttribute('wasd-controls', {fly: true}); 426 | this.spectatorCameraEl = spectatorCameraEl; 427 | 428 | // Append rig. 429 | spectatorCameraRigEl.appendChild(spectatorCameraEl); 430 | sceneEl.appendChild(spectatorCameraRigEl); 431 | sceneEl.emit('spectatorcameracreated'); 432 | }, 433 | 434 | /** 435 | * Check for recording sources and play. 436 | */ 437 | replayRecordingFromSource: function () { 438 | var data = this.data; 439 | var recordingdb = this.el.systems.recordingdb;; 440 | var recordingNames; 441 | var src; 442 | var self = this; 443 | 444 | // Allow override to display replayer from query param. 445 | if (new URLSearchParams(window.location.search).get('avatar-replayer-disabled') !== null) { 446 | return; 447 | } 448 | 449 | recordingdb.getRecordingNames().then(function (recordingNames) { 450 | // See if recording defined in query parameter. 451 | var queryParamSrc = self.getSrcFromSearchParam(); 452 | 453 | // 1. Try `avatar-recorder` query parameter as recording name from IndexedDB. 454 | if (recordingNames.indexOf(queryParamSrc) !== -1) { 455 | log('Replaying `' + queryParamSrc + '` from IndexedDB.'); 456 | recordingdb.getRecording(queryParamSrc).then(bind(self.startReplaying, self)); 457 | return; 458 | } 459 | 460 | // 2. Use `avatar-recorder` query parameter or `data.src` as URL. 461 | src = queryParamSrc || self.data.src; 462 | if (src) { 463 | if (self.data.src) { 464 | log('Replaying from component `src`', src); 465 | } else if (queryParamSrc) { 466 | log('Replaying from query parameter `recording`', src); 467 | } 468 | self.loadRecordingFromUrl(src, false, bind(self.startReplaying, self)); 469 | return; 470 | } 471 | 472 | // 3. Use `data.recordingName` as recording name from IndexedDB. 473 | if (recordingNames.indexOf(self.data.recordingName) !== -1) { 474 | log('Replaying `' + self.data.recordingName + '` from IndexedDB.'); 475 | recordingdb.getRecording(self.data.recordingName).then(bind(self.startReplaying, self)); 476 | } 477 | }); 478 | }, 479 | 480 | /** 481 | * Defined for test stubbing. 482 | */ 483 | getSrcFromSearchParam: function () { 484 | var search = new URLSearchParams(window.location.search); 485 | return search.get('recording') || search.get('avatar-recording'); 486 | }, 487 | 488 | /** 489 | * Set player on camera and controllers (marked by ID). 490 | * 491 | * @params {object} replayData - { 492 | * camera: {poses: [], events: []}, 493 | * [c1ID]: {poses: [], events: []}, 494 | * [c2ID]: {poses: [], events: []} 495 | * } 496 | */ 497 | startReplaying: function (replayData) { 498 | var data = this.data; 499 | var self = this; 500 | var sceneEl = this.el; 501 | 502 | if (this.isReplaying) { return; } 503 | 504 | // Wait for camera. 505 | if (!this.el.camera) { 506 | this.el.addEventListener('camera-set-active', function waitForCamera () { 507 | self.startReplaying(replayData); 508 | self.el.removeEventListener('camera-set-active', waitForCamera); 509 | }); 510 | return; 511 | } 512 | 513 | this.replayData = replayData; 514 | this.isReplaying = true; 515 | 516 | this.cameraEl.removeAttribute('motion-capture-replayer'); 517 | 518 | Object.keys(replayData).forEach(function setReplayer (key) { 519 | var replayingEl; 520 | 521 | if (key === 'camera') { 522 | // Grab camera. 523 | replayingEl = self.cameraEl; 524 | } else { 525 | // Grab other entities. 526 | replayingEl = sceneEl.querySelector('#' + key); 527 | if (!replayingEl) { 528 | error('No element found with ID ' + key + '.'); 529 | return; 530 | } 531 | } 532 | 533 | log('Setting motion-capture-replayer on ' + key + '.'); 534 | replayingEl.setAttribute('motion-capture-replayer', {loop: data.loop}); 535 | replayingEl.components['motion-capture-replayer'].startReplaying(replayData[key]); 536 | }); 537 | }, 538 | 539 | /** 540 | * Create head geometry for spectator mode. 541 | * Always created in case we want to toggle, but only visible during spectator mode. 542 | */ 543 | configureHeadGeometry: function () { 544 | var cameraEl = this.cameraEl; 545 | var headMesh; 546 | var leftEyeMesh; 547 | var rightEyeMesh; 548 | var leftEyeBallMesh; 549 | var rightEyeBallMesh; 550 | 551 | if (cameraEl.getObject3D('mesh') || cameraEl.getObject3D('replayerMesh')) { return; } 552 | 553 | // Head. 554 | headMesh = new THREE.Mesh(); 555 | headMesh.geometry = new THREE.BoxBufferGeometry(0.3, 0.3, 0.2); 556 | headMesh.material = new THREE.MeshStandardMaterial({color: 'pink'}); 557 | headMesh.visible = this.data.spectatorMode; 558 | 559 | // Left eye. 560 | leftEyeMesh = new THREE.Mesh(); 561 | leftEyeMesh.geometry = new THREE.SphereBufferGeometry(0.05); 562 | leftEyeMesh.material = new THREE.MeshBasicMaterial({color: 'white'}); 563 | leftEyeMesh.position.x -= 0.1; 564 | leftEyeMesh.position.y += 0.1; 565 | leftEyeMesh.position.z -= 0.1; 566 | leftEyeBallMesh = new THREE.Mesh(); 567 | leftEyeBallMesh.geometry = new THREE.SphereBufferGeometry(0.025); 568 | leftEyeBallMesh.material = new THREE.MeshBasicMaterial({color: 'black'}); 569 | leftEyeBallMesh.position.z -= 0.04; 570 | leftEyeMesh.add(leftEyeBallMesh); 571 | headMesh.add(leftEyeMesh); 572 | 573 | // Right eye. 574 | rightEyeMesh = new THREE.Mesh(); 575 | rightEyeMesh.geometry = new THREE.SphereBufferGeometry(0.05); 576 | rightEyeMesh.material = new THREE.MeshBasicMaterial({color: 'white'}); 577 | rightEyeMesh.position.x += 0.1; 578 | rightEyeMesh.position.y += 0.1; 579 | rightEyeMesh.position.z -= 0.1; 580 | rightEyeBallMesh = new THREE.Mesh(); 581 | rightEyeBallMesh.geometry = new THREE.SphereBufferGeometry(0.025); 582 | rightEyeBallMesh.material = new THREE.MeshBasicMaterial({color: 'black'}); 583 | rightEyeBallMesh.position.z -= 0.04; 584 | rightEyeMesh.add(rightEyeBallMesh); 585 | headMesh.add(rightEyeMesh); 586 | 587 | cameraEl.setObject3D('replayerMesh', headMesh); 588 | }, 589 | 590 | /** 591 | * Remove motion-capture-replayer components. 592 | */ 593 | stopReplaying: function () { 594 | var self = this; 595 | 596 | if (!this.isReplaying || !this.replayData) { return; } 597 | 598 | this.isReplaying = false; 599 | Object.keys(this.replayData).forEach(function removeReplayer (key) { 600 | if (key === 'camera') { 601 | self.cameraEl.removeComponent('motion-capture-replayer'); 602 | } else { 603 | el = document.querySelector('#' + key); 604 | if (!el) { 605 | warn('No element with id ' + key); 606 | return; 607 | } 608 | el.removeComponent('motion-capture-replayer'); 609 | } 610 | }); 611 | }, 612 | 613 | /** 614 | * XHR for data. 615 | */ 616 | loadRecordingFromUrl: function (url, binary, callback) { 617 | var data; 618 | var self = this; 619 | fileLoader.crossOrigin = 'anonymous'; 620 | if (binary === true) { 621 | fileLoader.setResponseType('arraybuffer'); 622 | } 623 | fileLoader.load(url, function (buffer) { 624 | if (binary === true) { 625 | data = self.loadStrokeBinary(buffer); 626 | } else { 627 | data = JSON.parse(buffer); 628 | } 629 | if (callback) { callback(data); } 630 | }); 631 | } 632 | }); 633 | 634 | },{"../constants":8}],5:[function(require,module,exports){ 635 | /* global AFRAME, THREE */ 636 | 637 | var EVENTS = { 638 | axismove: {id: 0, props: ['id', 'axis', 'changed']}, 639 | buttonchanged: {id: 1, props: ['id', 'state']}, 640 | buttondown: {id: 2, props: ['id', 'state']}, 641 | buttonup: {id: 3, props: ['id', 'state']}, 642 | touchstart: {id: 4, props: ['id', 'state']}, 643 | touchend: {id: 5, props: ['id', 'state']} 644 | }; 645 | 646 | var EVENTS_DECODE = { 647 | 0: 'axismove', 648 | 1: 'buttonchanged', 649 | 2: 'buttondown', 650 | 3: 'buttonup', 651 | 4: 'touchstart', 652 | 5: 'touchend' 653 | }; 654 | 655 | AFRAME.registerComponent('motion-capture-recorder', { 656 | schema: { 657 | autoRecord: {default: false}, 658 | enabled: {default: true}, 659 | hand: {default: 'right'}, 660 | recordingControls: {default: false}, 661 | persistStroke: {default: false}, 662 | visibleStroke: {default: true} 663 | }, 664 | 665 | init: function () { 666 | this.drawing = false; 667 | this.recordedEvents = []; 668 | this.recordedPoses = []; 669 | this.addEventListeners(); 670 | }, 671 | 672 | addEventListeners: function () { 673 | var el = this.el; 674 | this.recordEvent = this.recordEvent.bind(this); 675 | el.addEventListener('axismove', this.recordEvent); 676 | el.addEventListener('buttonchanged', this.onTriggerChanged.bind(this)); 677 | el.addEventListener('buttonchanged', this.recordEvent); 678 | el.addEventListener('buttonup', this.recordEvent); 679 | el.addEventListener('buttondown', this.recordEvent); 680 | el.addEventListener('touchstart', this.recordEvent); 681 | el.addEventListener('touchend', this.recordEvent); 682 | }, 683 | 684 | recordEvent: function (evt) { 685 | var detail; 686 | if (!this.isRecording) { return; } 687 | 688 | // Filter out `target`, not serializable. 689 | if ('detail' in evt && 'state' in evt.detail && typeof evt.detail.state === 'object' && 690 | 'target' in evt.detail.state) { 691 | delete evt.detail.state.target; 692 | } 693 | 694 | detail = {}; 695 | EVENTS[evt.type].props.forEach(function buildDetail (propName) { 696 | // Convert GamepadButton to normal JS object. 697 | if (propName === 'state') { 698 | var stateProp; 699 | detail.state = {}; 700 | for (stateProp in evt.detail.state) { 701 | detail.state[stateProp] = evt.detail.state[stateProp]; 702 | } 703 | return; 704 | } 705 | detail[propName] = evt.detail[propName]; 706 | }); 707 | 708 | this.recordedEvents.push({ 709 | name: evt.type, 710 | detail: detail, 711 | timestamp: this.lastTimestamp 712 | }); 713 | }, 714 | 715 | onTriggerChanged: function (evt) { 716 | var data = this.data; 717 | var value; 718 | if (!data.enabled || data.autoRecord) { return; } 719 | // Not Trigger 720 | if (evt.detail.id !== 1 || !this.data.recordingControls) { return; } 721 | value = evt.detail.state.value; 722 | if (value <= 0.1) { 723 | if (this.isRecording) { this.stopRecording(); } 724 | return; 725 | } 726 | if (!this.isRecording) { this.startRecording(); } 727 | }, 728 | 729 | getJSONData: function () { 730 | var data; 731 | var trackedControlsComponent = this.el.components['tracked-controls']; 732 | var controller = trackedControlsComponent && trackedControlsComponent.controller; 733 | if (!this.recordedPoses) { return; } 734 | data = { 735 | poses: this.getStrokeJSON(this.recordedPoses), 736 | events: this.recordedEvents 737 | }; 738 | if (controller) { 739 | data.gamepad = { 740 | id: controller.id, 741 | hand: controller.hand, 742 | index: controller.index 743 | }; 744 | } 745 | return data; 746 | }, 747 | 748 | getStrokeJSON: function (stroke) { 749 | var point; 750 | var points = []; 751 | for (var i = 0; i < stroke.length; i++) { 752 | point = stroke[i]; 753 | points.push({ 754 | position: point.position, 755 | rotation: point.rotation, 756 | timestamp: point.timestamp 757 | }); 758 | } 759 | return points; 760 | }, 761 | 762 | saveCapture: function (binary) { 763 | var jsonData = JSON.stringify(this.getJSONData()); 764 | var type = binary ? 'application/octet-binary' : 'application/json'; 765 | var blob = new Blob([jsonData], {type: type}); 766 | var url = URL.createObjectURL(blob); 767 | var fileName = 'motion-capture-' + document.title + '-' + Date.now() + '.json'; 768 | var aEl = document.createElement('a'); 769 | aEl.setAttribute('class', 'motion-capture-download'); 770 | aEl.href = url; 771 | aEl.setAttribute('download', fileName); 772 | aEl.innerHTML = 'downloading...'; 773 | aEl.style.display = 'none'; 774 | document.body.appendChild(aEl); 775 | setTimeout(function () { 776 | aEl.click(); 777 | document.body.removeChild(aEl); 778 | }, 1); 779 | }, 780 | 781 | update: function () { 782 | var el = this.el; 783 | var data = this.data; 784 | if (this.data.autoRecord) { 785 | this.startRecording(); 786 | } else { 787 | // Don't try to record camera with controllers. 788 | if (el.components.camera) { return; } 789 | 790 | if (data.recordingControls) { 791 | el.setAttribute('vive-controls', {hand: data.hand}); 792 | el.setAttribute('oculus-touch-controls', {hand: data.hand}); 793 | } 794 | el.setAttribute('stroke', ''); 795 | } 796 | }, 797 | 798 | tick: (function () { 799 | var position = new THREE.Vector3(); 800 | var rotation = new THREE.Quaternion(); 801 | var scale = new THREE.Vector3(); 802 | 803 | return function (time, delta) { 804 | var newPoint; 805 | var pointerPosition; 806 | this.lastTimestamp = time; 807 | if (!this.data.enabled || !this.isRecording) { return; } 808 | newPoint = { 809 | position: AFRAME.utils.clone(this.el.getAttribute('position')), 810 | rotation: AFRAME.utils.clone(this.el.getAttribute('rotation')), 811 | timestamp: time 812 | }; 813 | this.recordedPoses.push(newPoint); 814 | if (!this.data.visibleStroke) { return; } 815 | this.el.object3D.updateMatrixWorld(); 816 | this.el.object3D.matrixWorld.decompose(position, rotation, scale); 817 | pointerPosition = this.getPointerPosition(position, rotation); 818 | this.el.components.stroke.drawPoint(position, rotation, time, pointerPosition); 819 | }; 820 | })(), 821 | 822 | getPointerPosition: (function () { 823 | var pointerPosition = new THREE.Vector3(); 824 | var offset = new THREE.Vector3(0, 0.7, 1); 825 | return function getPointerPosition (position, orientation) { 826 | var pointer = offset 827 | .clone() 828 | .applyQuaternion(orientation) 829 | .normalize() 830 | .multiplyScalar(-0.03); 831 | pointerPosition.copy(position).add(pointer); 832 | return pointerPosition; 833 | }; 834 | })(), 835 | 836 | startRecording: function () { 837 | var el = this.el; 838 | if (this.isRecording) { return; } 839 | if (el.components.stroke) { el.components.stroke.reset(); } 840 | this.isRecording = true; 841 | this.recordedPoses = []; 842 | this.recordedEvents = []; 843 | el.emit('strokestarted', {entity: el, poses: this.recordedPoses}); 844 | }, 845 | 846 | stopRecording: function () { 847 | var el = this.el; 848 | if (!this.isRecording) { return; } 849 | el.emit('strokeended', {poses: this.recordedPoses}); 850 | this.isRecording = false; 851 | if (!this.data.visibleStroke || this.data.persistStroke) { return; } 852 | el.components.stroke.reset(); 853 | } 854 | }); 855 | 856 | },{}],6:[function(require,module,exports){ 857 | /* global THREE, AFRAME */ 858 | AFRAME.registerComponent('motion-capture-replayer', { 859 | schema: { 860 | enabled: {default: true}, 861 | recorderEl: {type: 'selector'}, 862 | loop: {default: false}, 863 | src: {default: ''}, 864 | spectatorCamera: {default: false} 865 | }, 866 | 867 | init: function () { 868 | this.currentPoseTime = 0; 869 | this.currentEventTime = 0; 870 | this.currentPoseIndex = 0; 871 | this.currentEventIndex = 0; 872 | this.onStrokeStarted = this.onStrokeStarted.bind(this); 873 | this.onStrokeEnded = this.onStrokeEnded.bind(this); 874 | this.playComponent = this.playComponent.bind(this); 875 | this.el.addEventListener('pause', this.playComponent); 876 | this.discardedFrames = 0; 877 | this.playingEvents = []; 878 | this.playingPoses = []; 879 | this.gamepadData = null; 880 | }, 881 | 882 | remove: function () { 883 | var el = this.el; 884 | var gamepadData = this.gamepadData; 885 | var gamepads; 886 | var found = -1; 887 | 888 | el.removeEventListener('pause', this.playComponent); 889 | this.stopReplaying(); 890 | el.pause(); 891 | el.play(); 892 | 893 | // Remove gamepad from system. 894 | if (this.gamepadData) { 895 | gamepads = el.sceneEl.systems['motion-capture-replayer'].gamepads; 896 | gamepads.forEach(function (gamepad, i) { 897 | if (gamepad === gamepadData) { found = i; } 898 | }); 899 | if (found !== -1) { 900 | gamepads.splice(found, 1); 901 | } 902 | } 903 | }, 904 | 905 | update: function (oldData) { 906 | var data = this.data; 907 | this.updateRecorder(data.recorderEl, oldData.recorderEl); 908 | if (!this.el.isPlaying) { this.playComponent(); } 909 | if (oldData.src === data.src) { return; } 910 | if (data.src) { this.updateSrc(data.src); } 911 | }, 912 | 913 | updateRecorder: function (newRecorderEl, oldRecorderEl) { 914 | if (oldRecorderEl && oldRecorderEl !== newRecorderEl) { 915 | oldRecorderEl.removeEventListener('strokestarted', this.onStrokeStarted); 916 | oldRecorderEl.removeEventListener('strokeended', this.onStrokeEnded); 917 | } 918 | if (!newRecorderEl || oldRecorderEl === newRecorderEl) { return; } 919 | newRecorderEl.addEventListener('strokestarted', this.onStrokeStarted); 920 | newRecorderEl.addEventListener('strokeended', this.onStrokeEnded); 921 | }, 922 | 923 | updateSrc: function (src) { 924 | this.el.sceneEl.systems['motion-capture-recorder'].loadRecordingFromUrl( 925 | src, false, this.startReplaying.bind(this)); 926 | }, 927 | 928 | onStrokeStarted: function(evt) { 929 | this.reset(); 930 | }, 931 | 932 | onStrokeEnded: function(evt) { 933 | this.startReplayingPoses(evt.detail.poses); 934 | }, 935 | 936 | play: function () { 937 | if (this.playingStroke) { this.playStroke(this.playingStroke); } 938 | }, 939 | 940 | playComponent: function () { 941 | this.el.isPlaying = true; 942 | this.play(); 943 | }, 944 | 945 | /** 946 | * @param {object} data - Recording data. 947 | */ 948 | startReplaying: function (data) { 949 | var el = this.el; 950 | 951 | this.ignoredFrames = 0; 952 | this.storeInitialPose(); 953 | this.isReplaying = true; 954 | this.startReplayingPoses(data.poses); 955 | this.startReplayingEvents(data.events); 956 | 957 | // Add gamepad metadata to system. 958 | if (data.gamepad) { 959 | this.gamepadData = data.gamepad; 960 | el.sceneEl.systems['motion-capture-replayer'].gamepads.push(data.gamepad); 961 | el.emit('gamepadconnected'); 962 | } 963 | 964 | el.emit('replayingstarted'); 965 | }, 966 | 967 | stopReplaying: function () { 968 | this.isReplaying = false; 969 | this.restoreInitialPose(); 970 | this.el.emit('replayingstopped'); 971 | }, 972 | 973 | storeInitialPose: function () { 974 | var el = this.el; 975 | this.initialPose = { 976 | position: AFRAME.utils.clone(el.getAttribute('position')), 977 | rotation: AFRAME.utils.clone(el.getAttribute('rotation')) 978 | }; 979 | }, 980 | 981 | restoreInitialPose: function () { 982 | var el = this.el; 983 | if (!this.initialPose) { return; } 984 | el.setAttribute('position', this.initialPose.position); 985 | el.setAttribute('rotation', this.initialPose.rotation); 986 | }, 987 | 988 | startReplayingPoses: function (poses) { 989 | this.isReplaying = true; 990 | this.currentPoseIndex = 0; 991 | if (poses.length === 0) { return; } 992 | this.playingPoses = poses; 993 | this.currentPoseTime = poses[0].timestamp; 994 | }, 995 | 996 | /** 997 | * @param events {Array} - Array of events with timestamp, name, and detail. 998 | */ 999 | startReplayingEvents: function (events) { 1000 | var firstEvent; 1001 | this.isReplaying = true; 1002 | this.currentEventIndex = 0; 1003 | if (events.length === 0) { return; } 1004 | firstEvent = events[0]; 1005 | this.playingEvents = events; 1006 | this.currentEventTime = firstEvent.timestamp; 1007 | this.el.emit(firstEvent.name, firstEvent.detail); 1008 | }, 1009 | 1010 | // Reset player 1011 | reset: function () { 1012 | this.playingPoses = null; 1013 | this.currentTime = undefined; 1014 | this.currentPoseIndex = undefined; 1015 | }, 1016 | 1017 | /** 1018 | * Called on tick. 1019 | */ 1020 | playRecording: function (delta) { 1021 | var currentPose; 1022 | var currentEvent 1023 | var playingPoses = this.playingPoses; 1024 | var playingEvents = this.playingEvents; 1025 | currentPose = playingPoses && playingPoses[this.currentPoseIndex] 1026 | currentEvent = playingEvents && playingEvents[this.currentEventIndex]; 1027 | this.currentPoseTime += delta; 1028 | this.currentEventTime += delta; 1029 | // Determine next pose. 1030 | // Comparing currentPoseTime to currentEvent.timestamp is not a typo. 1031 | while ((currentPose && this.currentPoseTime >= currentPose.timestamp) || 1032 | (currentEvent && this.currentPoseTime >= currentEvent.timestamp)) { 1033 | // Pose. 1034 | if (currentPose && this.currentPoseTime >= currentPose.timestamp) { 1035 | if (this.currentPoseIndex === playingPoses.length - 1) { 1036 | if (this.data.loop) { 1037 | this.currentPoseIndex = 0; 1038 | this.currentPoseTime = playingPoses[0].timestamp; 1039 | } else { 1040 | this.stopReplaying(); 1041 | } 1042 | } 1043 | applyPose(this.el, currentPose); 1044 | this.currentPoseIndex += 1; 1045 | currentPose = playingPoses[this.currentPoseIndex]; 1046 | } 1047 | // Event. 1048 | if (currentEvent && this.currentPoseTime >= currentEvent.timestamp) { 1049 | if (this.currentEventIndex === playingEvents.length && this.data.loop) { 1050 | this.currentEventIndex = 0; 1051 | this.currentEventTime = playingEvents[0].timestamp; 1052 | } 1053 | this.el.emit(currentEvent.name, currentEvent.detail); 1054 | this.currentEventIndex += 1; 1055 | currentEvent = this.playingEvents[this.currentEventIndex]; 1056 | } 1057 | } 1058 | }, 1059 | 1060 | tick: function (time, delta) { 1061 | // Ignore the first couple of frames that come from window.RAF on Firefox. 1062 | if (this.ignoredFrames !== 2 && !window.debug) { 1063 | this.ignoredFrames++; 1064 | return; 1065 | } 1066 | 1067 | if (!this.isReplaying) { return; } 1068 | this.playRecording(delta); 1069 | } 1070 | }); 1071 | 1072 | function applyPose (el, pose) { 1073 | el.setAttribute('position', pose.position); 1074 | el.setAttribute('rotation', pose.rotation); 1075 | }; 1076 | 1077 | },{}],7:[function(require,module,exports){ 1078 | /* global THREE AFRAME */ 1079 | AFRAME.registerComponent('stroke', { 1080 | schema: { 1081 | enabled: {default: true}, 1082 | color: {default: '#ef2d5e', type: 'color'} 1083 | }, 1084 | 1085 | init: function () { 1086 | var maxPoints = this.maxPoints = 3000; 1087 | var strokeEl; 1088 | this.idx = 0; 1089 | this.numPoints = 0; 1090 | 1091 | // Buffers 1092 | this.vertices = new Float32Array(maxPoints*3*3); 1093 | this.normals = new Float32Array(maxPoints*3*3); 1094 | this.uvs = new Float32Array(maxPoints*2*2); 1095 | 1096 | // Geometries 1097 | this.geometry = new THREE.BufferGeometry(); 1098 | this.geometry.setDrawRange(0, 0); 1099 | this.geometry.addAttribute('position', new THREE.BufferAttribute(this.vertices, 3).setDynamic(true)); 1100 | this.geometry.addAttribute('uv', new THREE.BufferAttribute(this.uvs, 2).setDynamic(true)); 1101 | this.geometry.addAttribute('normal', new THREE.BufferAttribute(this.normals, 3).setDynamic(true)); 1102 | 1103 | this.material = new THREE.MeshStandardMaterial({ 1104 | color: this.data.color, 1105 | roughness: 0.75, 1106 | metalness: 0.25, 1107 | side: THREE.DoubleSide 1108 | }); 1109 | 1110 | var mesh = new THREE.Mesh(this.geometry, this.material); 1111 | mesh.drawMode = THREE.TriangleStripDrawMode; 1112 | mesh.frustumCulled = false; 1113 | 1114 | // Injects stroke entity 1115 | strokeEl = document.createElement('a-entity'); 1116 | strokeEl.setObject3D('stroke', mesh); 1117 | this.el.sceneEl.appendChild(strokeEl); 1118 | }, 1119 | 1120 | update: function() { 1121 | this.material.color.set(this.data.color); 1122 | }, 1123 | 1124 | drawPoint: (function () { 1125 | var direction = new THREE.Vector3(); 1126 | var positionA = new THREE.Vector3(); 1127 | var positionB = new THREE.Vector3(); 1128 | return function (position, orientation, timestamp, pointerPosition) { 1129 | var uv = 0; 1130 | var numPoints = this.numPoints; 1131 | var brushSize = 0.01; 1132 | if (numPoints === this.maxPoints) { return; } 1133 | for (i = 0; i < numPoints; i++) { 1134 | this.uvs[uv++] = i / (numPoints - 1); 1135 | this.uvs[uv++] = 0; 1136 | 1137 | this.uvs[uv++] = i / (numPoints - 1); 1138 | this.uvs[uv++] = 1; 1139 | } 1140 | 1141 | direction.set(1, 0, 0); 1142 | direction.applyQuaternion(orientation); 1143 | direction.normalize(); 1144 | 1145 | positionA.copy(pointerPosition); 1146 | positionB.copy(pointerPosition); 1147 | positionA.add(direction.clone().multiplyScalar(brushSize / 2)); 1148 | positionB.add(direction.clone().multiplyScalar(-brushSize / 2)); 1149 | 1150 | this.vertices[this.idx++] = positionA.x; 1151 | this.vertices[this.idx++] = positionA.y; 1152 | this.vertices[this.idx++] = positionA.z; 1153 | 1154 | this.vertices[this.idx++] = positionB.x; 1155 | this.vertices[this.idx++] = positionB.y; 1156 | this.vertices[this.idx++] = positionB.z; 1157 | 1158 | this.computeVertexNormals(); 1159 | this.geometry.attributes.normal.needsUpdate = true; 1160 | this.geometry.attributes.position.needsUpdate = true; 1161 | this.geometry.attributes.uv.needsUpdate = true; 1162 | 1163 | this.geometry.setDrawRange(0, numPoints * 2); 1164 | this.numPoints += 1; 1165 | return true; 1166 | } 1167 | })(), 1168 | 1169 | reset: function () { 1170 | var idx = 0; 1171 | var vertices = this.vertices; 1172 | for (i = 0; i < this.numPoints; i++) { 1173 | vertices[idx++] = 0; 1174 | vertices[idx++] = 0; 1175 | vertices[idx++] = 0; 1176 | 1177 | vertices[idx++] = 0; 1178 | vertices[idx++] = 0; 1179 | vertices[idx++] = 0; 1180 | } 1181 | this.geometry.setDrawRange(0, 0); 1182 | this.idx = 0; 1183 | this.numPoints = 0; 1184 | }, 1185 | 1186 | computeVertexNormals: function () { 1187 | var pA = new THREE.Vector3(); 1188 | var pB = new THREE.Vector3(); 1189 | var pC = new THREE.Vector3(); 1190 | var cb = new THREE.Vector3(); 1191 | var ab = new THREE.Vector3(); 1192 | 1193 | for (var i = 0, il = this.idx; i < il; i++) { 1194 | this.normals[ i ] = 0; 1195 | } 1196 | 1197 | var pair = true; 1198 | for (i = 0, il = this.idx; i < il; i += 3) { 1199 | if (pair) { 1200 | pA.fromArray(this.vertices, i); 1201 | pB.fromArray(this.vertices, i + 3); 1202 | pC.fromArray(this.vertices, i + 6); 1203 | } else { 1204 | pA.fromArray(this.vertices, i + 3); 1205 | pB.fromArray(this.vertices, i); 1206 | pC.fromArray(this.vertices, i + 6); 1207 | } 1208 | pair = !pair; 1209 | 1210 | cb.subVectors(pC, pB); 1211 | ab.subVectors(pA, pB); 1212 | cb.cross(ab); 1213 | cb.normalize(); 1214 | 1215 | this.normals[i] += cb.x; 1216 | this.normals[i + 1] += cb.y; 1217 | this.normals[i + 2] += cb.z; 1218 | 1219 | this.normals[i + 3] += cb.x; 1220 | this.normals[i + 4] += cb.y; 1221 | this.normals[i + 5] += cb.z; 1222 | 1223 | this.normals[i + 6] += cb.x; 1224 | this.normals[i + 7] += cb.y; 1225 | this.normals[i + 8] += cb.z; 1226 | } 1227 | 1228 | /* 1229 | first and last vertice (0 and 8) belongs just to one triangle 1230 | second and penultimate (1 and 7) belongs to two triangles 1231 | the rest of the vertices belongs to three triangles 1232 | 1233 | 1_____3_____5_____7 1234 | /\ /\ /\ /\ 1235 | / \ / \ / \ / \ 1236 | /____\/____\/____\/____\ 1237 | 0 2 4 6 8 1238 | */ 1239 | 1240 | // Vertices that are shared across three triangles 1241 | for (i = 2 * 3, il = this.idx - 2 * 3; i < il; i++) { 1242 | this.normals[ i ] = this.normals[ i ] / 3; 1243 | } 1244 | 1245 | // Second and penultimate triangle, that shares just two triangles 1246 | this.normals[ 3 ] = this.normals[ 3 ] / 2; 1247 | this.normals[ 3 + 1 ] = this.normals[ 3 + 1 ] / 2; 1248 | this.normals[ 3 + 2 ] = this.normals[ 3 * 1 + 2 ] / 2; 1249 | 1250 | this.normals[ this.idx - 2 * 3 ] = this.normals[ this.idx - 2 * 3 ] / 2; 1251 | this.normals[ this.idx - 2 * 3 + 1 ] = this.normals[ this.idx - 2 * 3 + 1 ] / 2; 1252 | this.normals[ this.idx - 2 * 3 + 2 ] = this.normals[ this.idx - 2 * 3 + 2 ] / 2; 1253 | 1254 | this.geometry.normalizeNormals(); 1255 | } 1256 | }); 1257 | 1258 | },{}],8:[function(require,module,exports){ 1259 | module.exports.LOCALSTORAGE_RECORDINGS = 'avatarRecordings'; 1260 | module.exports.DEFAULT_RECORDING_NAME = 'default'; 1261 | 1262 | },{}],9:[function(require,module,exports){ 1263 | if (typeof AFRAME === 'undefined') { 1264 | throw new Error('Component attempted to register before AFRAME was available.'); 1265 | } 1266 | 1267 | // Components. 1268 | require('./components/motion-capture-recorder.js'); 1269 | require('./components/motion-capture-replayer.js'); 1270 | require('./components/avatar-recorder.js'); 1271 | require('./components/avatar-replayer.js'); 1272 | require('./components/stroke.js'); 1273 | 1274 | // Systems. 1275 | require('./systems/motion-capture-replayer.js'); 1276 | require('./systems/recordingdb.js'); 1277 | 1278 | },{"./components/avatar-recorder.js":3,"./components/avatar-replayer.js":4,"./components/motion-capture-recorder.js":5,"./components/motion-capture-replayer.js":6,"./components/stroke.js":7,"./systems/motion-capture-replayer.js":10,"./systems/recordingdb.js":11}],10:[function(require,module,exports){ 1279 | AFRAME.registerSystem('motion-capture-replayer', { 1280 | init: function () { 1281 | var sceneEl = this.sceneEl; 1282 | var trackedControlsComponent; 1283 | var trackedControlsSystem; 1284 | var trackedControlsTick; 1285 | 1286 | trackedControlsSystem = sceneEl.systems['tracked-controls']; 1287 | trackedControlsTick = AFRAME.components['tracked-controls'].Component.prototype.tick; 1288 | 1289 | // Gamepad data stored in recording and added here by `motion-capture-replayer` component. 1290 | this.gamepads = []; 1291 | 1292 | // Wrap `updateControllerList`. 1293 | this.updateControllerListOriginal = trackedControlsSystem.updateControllerList.bind( 1294 | trackedControlsSystem); 1295 | trackedControlsSystem.updateControllerList = this.updateControllerList.bind(this); 1296 | 1297 | // Wrap `tracked-controls` tick. 1298 | trackedControlsComponent = AFRAME.components['tracked-controls'].Component.prototype; 1299 | trackedControlsComponent.tick = this.trackedControlsTickWrapper; 1300 | trackedControlsComponent.trackedControlsTick = trackedControlsTick; 1301 | }, 1302 | 1303 | remove: function () { 1304 | // restore modified objects 1305 | var trackedControlsComponent = AFRAME.components['tracked-controls'].Component.prototype; 1306 | var trackedControlsSystem = this.sceneEl.systems['tracked-controls']; 1307 | trackedControlsComponent.tick = trackedControlsComponent.trackedControlsTick; 1308 | delete trackedControlsComponent.trackedControlsTick; 1309 | trackedControlsSystem.updateControllerList = this.updateControllerListOriginal; 1310 | }, 1311 | 1312 | trackedControlsTickWrapper: function (time, delta) { 1313 | if (this.el.components['motion-capture-replayer']) { return; } 1314 | this.trackedControlsTick(time, delta); 1315 | }, 1316 | 1317 | /** 1318 | * Wrap `updateControllerList` to stub in the gamepads and emit `controllersupdated`. 1319 | */ 1320 | updateControllerList: function () { 1321 | var i; 1322 | var sceneEl = this.sceneEl; 1323 | var trackedControlsSystem = sceneEl.systems['tracked-controls']; 1324 | 1325 | this.updateControllerListOriginal(); 1326 | 1327 | this.gamepads.forEach(function (gamepad) { 1328 | if (trackedControlsSystem.controllers[gamepad.index]) { return; } 1329 | trackedControlsSystem.controllers[gamepad.index] = gamepad; 1330 | }); 1331 | 1332 | for (i = 0; i < trackedControlsSystem.controllers.length; i++) { 1333 | if (trackedControlsSystem.controllers[i]) { continue; } 1334 | trackedControlsSystem.controllers[i] = {id: '___', index: -1, hand: 'finger'}; 1335 | } 1336 | 1337 | sceneEl.emit('controllersupdated', undefined, false); 1338 | } 1339 | }); 1340 | 1341 | },{}],11:[function(require,module,exports){ 1342 | /* global indexedDB */ 1343 | var constants = require('../constants'); 1344 | 1345 | var DB_NAME = 'motionCaptureRecordings'; 1346 | var OBJECT_STORE_NAME = 'recordings'; 1347 | var VERSION = 1; 1348 | 1349 | /** 1350 | * Interface for storing and accessing recordings from Indexed DB. 1351 | */ 1352 | AFRAME.registerSystem('recordingdb', { 1353 | init: function () { 1354 | var request; 1355 | var self = this; 1356 | 1357 | this.db = null; 1358 | this.hasLoaded = false; 1359 | 1360 | request = indexedDB.open(DB_NAME, VERSION); 1361 | 1362 | request.onerror = function () { 1363 | console.error('Error opening IndexedDB for motion capture.', request.error); 1364 | }; 1365 | 1366 | // Initialize database. 1367 | request.onupgradeneeded = function (evt) { 1368 | var db = self.db = evt.target.result; 1369 | var objectStore; 1370 | 1371 | // Create object store. 1372 | objectStore = db.createObjectStore('recordings', { 1373 | autoIncrement: false 1374 | }); 1375 | objectStore.createIndex('recordingName', 'recordingName', {unique: true}); 1376 | self.objectStore = objectStore; 1377 | }; 1378 | 1379 | // Got database. 1380 | request.onsuccess = function (evt) { 1381 | self.db = evt.target.result; 1382 | self.hasLoaded = true; 1383 | self.sceneEl.emit('recordingdbinitialized'); 1384 | }; 1385 | }, 1386 | 1387 | /** 1388 | * Need a new transaction for everything. 1389 | */ 1390 | getTransaction: function () { 1391 | var transaction = this.db.transaction([OBJECT_STORE_NAME], 'readwrite'); 1392 | return transaction.objectStore(OBJECT_STORE_NAME); 1393 | }, 1394 | 1395 | getRecordingNames: function () { 1396 | var self = this; 1397 | return new Promise(function (resolve) { 1398 | var recordingNames = []; 1399 | 1400 | self.waitForDb(function () { 1401 | self.getTransaction().openCursor().onsuccess = function (evt) { 1402 | var cursor = evt.target.result; 1403 | 1404 | // No recordings. 1405 | if (!cursor) { 1406 | resolve(recordingNames.sort()); 1407 | return; 1408 | } 1409 | 1410 | recordingNames.push(cursor.key); 1411 | cursor.continue(); 1412 | }; 1413 | }); 1414 | }); 1415 | }, 1416 | 1417 | getRecordings: function (cb) { 1418 | var self = this; 1419 | return new Promise(function getRecordings (resolve) { 1420 | self.waitForDb(function () { 1421 | self.getTransaction().openCursor().onsuccess = function (evt) { 1422 | var cursor = evt.target.result; 1423 | var recordings = [cursor.value]; 1424 | while (cursor.ontinue()) { 1425 | recordings.push(cursor.value); 1426 | } 1427 | resolve(recordings); 1428 | }; 1429 | }); 1430 | }); 1431 | }, 1432 | 1433 | getRecording: function (name) { 1434 | var self = this; 1435 | return new Promise(function getRecording (resolve) { 1436 | self.waitForDb(function () { 1437 | self.getTransaction().get(name).onsuccess = function (evt) { 1438 | resolve(evt.target.result); 1439 | }; 1440 | }); 1441 | }); 1442 | }, 1443 | 1444 | addRecording: function (name, data) { 1445 | this.getTransaction().add(data, name); 1446 | }, 1447 | 1448 | deleteRecording: function (name) { 1449 | this.getTransaction().delete(name); 1450 | }, 1451 | 1452 | /** 1453 | * Helper to wait for store to be initialized before using it. 1454 | */ 1455 | waitForDb: function (cb) { 1456 | if (this.hasLoaded) { 1457 | cb(); 1458 | return; 1459 | } 1460 | this.sceneEl.addEventListener('recordingdbinitialized', cb); 1461 | } 1462 | }); 1463 | 1464 | },{"../constants":8}],12:[function(require,module,exports){ 1465 | 'use strict'; 1466 | 1467 | /* global AFRAME, THREE, CANNON */ 1468 | AFRAME.registerComponent('body-merger', { 1469 | schema: { default: 'static-body' }, 1470 | init: function () { 1471 | const doMerge = evt => { 1472 | if (evt.target === this.el) { 1473 | this.el.removeEventListener('body-loaded', doMerge); 1474 | this.merge(); 1475 | } 1476 | }; 1477 | if (this.el.body) { 1478 | this.merge(); 1479 | } else { 1480 | this.el.addEventListener('body-loaded', doMerge); 1481 | } 1482 | }, 1483 | merge: function () { 1484 | const body = this.el.body; 1485 | const tmpMat = new THREE.Matrix4(); 1486 | const tmpQuat = new THREE.Quaternion(); 1487 | const tmpPos = new THREE.Vector3(); 1488 | const tmpScale = new THREE.Vector3(1, 1, 1); // todo: apply worldScale 1489 | const offset = new CANNON.Vec3(); 1490 | const orientation = new CANNON.Quaternion(); 1491 | for (let child of this.el.childNodes) { 1492 | if (!child.body || !child.getAttribute(this.data)) { 1493 | continue; 1494 | } 1495 | child.object3D.updateMatrix(); 1496 | while (child.body.shapes.length) { 1497 | tmpPos.copy(child.body.shapeOffsets.pop()); 1498 | tmpQuat.copy(child.body.shapeOrientations.pop()); 1499 | tmpMat.compose(tmpPos, tmpQuat, tmpScale); 1500 | tmpMat.multiply(child.object3D.matrix); 1501 | tmpMat.decompose(tmpPos, tmpQuat, tmpScale); 1502 | offset.copy(tmpPos); 1503 | orientation.copy(tmpQuat); 1504 | body.addShape(child.body.shapes.pop(), offset, orientation); 1505 | } 1506 | child.removeAttribute(this.data); 1507 | } 1508 | } 1509 | }); 1510 | 1511 | },{}],13:[function(require,module,exports){ 1512 | 'use strict'; 1513 | 1514 | /* global AFRAME */ 1515 | AFRAME.registerComponent('physics-collider', { 1516 | schema: { 1517 | ignoreSleep: { default: true } 1518 | }, 1519 | init: function () { 1520 | this.collisions = new Set(); 1521 | this.currentCollisions = new Set(); 1522 | this.newCollisions = []; 1523 | this.clearedCollisions = []; 1524 | this.collisionEventDetails = { 1525 | els: this.newCollisions, 1526 | clearedEls: this.clearedCollisions 1527 | }; 1528 | }, 1529 | update: function () { 1530 | if (this.el.body) { 1531 | this.updateBody(); 1532 | } else { 1533 | this.el.addEventListener('body-loaded', this.updateBody.bind(this), { once: true }); 1534 | } 1535 | }, 1536 | tick: function () { 1537 | const uppperMask = 0xFFFF0000; 1538 | const lowerMask = 0x0000FFFF; 1539 | return function () { 1540 | if (!this.el.body) return; 1541 | const currentCollisions = this.currentCollisions; 1542 | const thisBodyId = this.el.body.id; 1543 | const worldCollisions = this.el.body.world.bodyOverlapKeeper.current; 1544 | const worldBodyMap = this.el.body.world.idToBodyMap; 1545 | const collisions = this.collisions; 1546 | const newCollisions = this.newCollisions; 1547 | const clearedCollisions = this.clearedCollisions; 1548 | let i = 0; 1549 | let upperId = (worldCollisions[i] & uppperMask) >> 16; 1550 | let target; 1551 | newCollisions.length = clearedCollisions.length = 0; 1552 | currentCollisions.clear(); 1553 | while (i < worldCollisions.length && upperId < thisBodyId) { 1554 | if (worldBodyMap[upperId]) { 1555 | target = worldBodyMap[upperId].el; 1556 | if ((worldCollisions[i] & lowerMask) === thisBodyId) { 1557 | currentCollisions.add(target); 1558 | if (!collisions.has(target)) { 1559 | newCollisions.push(target); 1560 | } 1561 | } 1562 | } 1563 | upperId = (worldCollisions[++i] & uppperMask) >> 16; 1564 | } 1565 | while (i < worldCollisions.length && upperId === thisBodyId) { 1566 | if (worldBodyMap[worldCollisions[i] & lowerMask]) { 1567 | target = worldBodyMap[worldCollisions[i] & lowerMask].el; 1568 | currentCollisions.add(target); 1569 | if (!collisions.has(target)) { 1570 | newCollisions.push(target); 1571 | } 1572 | } 1573 | upperId = (worldCollisions[++i] & uppperMask) >> 16; 1574 | } 1575 | 1576 | for (let col of collisions) { 1577 | if (!currentCollisions.has(col)) { 1578 | clearedCollisions.push(col); 1579 | collisions.delete(col); 1580 | } 1581 | } 1582 | for (let col of newCollisions) { 1583 | collisions.add(col); 1584 | } 1585 | if (newCollisions.length || clearedCollisions.length) { 1586 | this.el.emit('collisions', this.collisionEventDetails); 1587 | } 1588 | }; 1589 | }(), 1590 | remove: function () { 1591 | if (this.originalSleepConfig) { 1592 | AFRAME.utils.extend(this.el.body, this.originalSleepConfig); 1593 | } 1594 | }, 1595 | updateBody: function (evt) { 1596 | // ignore bubbled 'body-loaded' events 1597 | if (evt !== undefined && evt.target !== this.el) { 1598 | return; 1599 | } 1600 | if (this.data.ignoreSleep) { 1601 | // ensure sleep doesn't disable collision detection 1602 | this.el.body.allowSleep = false; 1603 | /* naiveBroadphase ignores collisions between sleeping & static bodies */ 1604 | this.el.body.type = window.CANNON.Body.KINEMATIC; 1605 | // Kinematics must have velocity >= their sleep limit to wake others 1606 | this.el.body.sleepSpeedLimit = 0; 1607 | } else if (this.originalSleepConfig === undefined) { 1608 | this.originalSleepConfig = { 1609 | allowSleep: this.el.body.allowSleep, 1610 | sleepSpeedLimit: this.el.body.sleepSpeedLimit, 1611 | type: this.el.body.type 1612 | }; 1613 | } else { 1614 | // restore original settings 1615 | AFRAME.utils.extend(this.el.body, this.originalSleepConfig); 1616 | } 1617 | } 1618 | }); 1619 | 1620 | },{}],14:[function(require,module,exports){ 1621 | 'use strict'; 1622 | 1623 | /* global AFRAME */ 1624 | AFRAME.registerComponent('collision-filter', { 1625 | schema: { 1626 | group: { default: 'default' }, 1627 | collidesWith: { default: ['default'] }, 1628 | collisionForces: { default: true } 1629 | }, 1630 | init: function () { 1631 | this.updateBodyBound = this.updateBody.bind(this); 1632 | this.system.registerMe(this); 1633 | this.el.addEventListener('body-loaded', this.updateBodyBound); 1634 | }, 1635 | update: function () { 1636 | // register any new groups 1637 | this.system.registerMe(this); 1638 | if (this.el.body) { 1639 | this.updateBody(); 1640 | } 1641 | }, 1642 | remove: function () { 1643 | this.el.removeEventListener('body-loaded', this.updateBodyBound); 1644 | }, 1645 | updateBody: function (evt) { 1646 | // ignore bubbled 'body-loaded' events 1647 | if (evt !== undefined && evt.target !== this.el) { 1648 | return; 1649 | } 1650 | this.el.body.collisionFilterMask = this.system.getFilterCode(this.data.collidesWith); 1651 | this.el.body.collisionFilterGroup = this.system.getFilterCode(this.data.group); 1652 | this.el.body.collisionResponse = this.data.collisionForces; 1653 | } 1654 | }); 1655 | 1656 | AFRAME.registerSystem('collision-filter', { 1657 | schema: { 1658 | collisionGroups: { default: ['default'] } 1659 | }, 1660 | dependencies: ['physics'], 1661 | init: function () { 1662 | this.maxGroups = Math.log2(Number.MAX_SAFE_INTEGER); 1663 | }, 1664 | registerMe: function (comp) { 1665 | // add any unknown groups to the master list 1666 | const newGroups = [comp.data.group, ...comp.data.collidesWith].filter(group => this.data.collisionGroups.indexOf(group) === -1); 1667 | this.data.collisionGroups.push(...newGroups); 1668 | if (this.data.collisionGroups.length > this.maxGroups) { 1669 | throw new Error('Too many collision groups'); 1670 | } 1671 | }, 1672 | getFilterCode: function (elGroups) { 1673 | let code = 0; 1674 | if (!Array.isArray(elGroups)) { 1675 | elGroups = [elGroups]; 1676 | } 1677 | // each group corresponds to a bit which is turned on when matched 1678 | // floor negates any unmatched groups (2^-1 = 0.5) 1679 | elGroups.forEach(group => { 1680 | code += Math.floor(Math.pow(2, this.data.collisionGroups.indexOf(group))); 1681 | }); 1682 | return code; 1683 | } 1684 | }); 1685 | 1686 | },{}],15:[function(require,module,exports){ 1687 | 'use strict'; 1688 | 1689 | // Make dynamic bodies idle when not grabbed 1690 | /* global AFRAME */ 1691 | AFRAME.registerComponent('sleepy', { 1692 | schema: { 1693 | allowSleep: { default: true }, 1694 | speedLimit: { default: 0.25, type: 'number' }, 1695 | delay: { default: 0.25, type: 'number' }, 1696 | linearDamping: { default: 0.99, type: 'number' }, 1697 | angularDamping: { default: 0.99, type: 'number' }, 1698 | holdState: { default: 'grabbed' } 1699 | }, 1700 | init: function () { 1701 | this.updateBodyBound = this.updateBody.bind(this); 1702 | this.holdStateBound = this.holdState.bind(this); 1703 | this.resumeStateBound = this.resumeState.bind(this); 1704 | 1705 | this.el.addEventListener('body-loaded', this.updateBodyBound); 1706 | }, 1707 | update: function () { 1708 | if (this.el.body) { 1709 | this.updateBody(); 1710 | } 1711 | }, 1712 | remove: function () { 1713 | this.el.removeEventListener('body-loaded', this.updateBodyBound); 1714 | this.el.removeEventListener('stateadded', this.holdStateBound); 1715 | this.el.removeEventListener('stateremoved', this.resumeStateBound); 1716 | }, 1717 | updateBody: function (evt) { 1718 | // ignore bubbled 'body-loaded' events 1719 | if (evt !== undefined && evt.target !== this.el) { 1720 | return; 1721 | } 1722 | if (this.data.allowSleep) { 1723 | // only "local" driver compatable 1724 | try { 1725 | this.el.body.world.allowSleep = true; 1726 | } catch (err) { 1727 | console.error('Unable to activate sleep in physics.' + '`sleepy` requires "local" physics driver'); 1728 | } 1729 | } 1730 | this.el.body.allowSleep = this.data.allowSleep; 1731 | this.el.body.sleepSpeedLimit = this.data.speedLimit; 1732 | this.el.body.sleepTimeLimit = this.data.delay; 1733 | this.el.body.linearDamping = this.data.linearDamping; 1734 | this.el.body.angularDamping = this.data.angularDamping; 1735 | if (this.data.allowSleep) { 1736 | this.el.addEventListener('stateadded', this.holdStateBound); 1737 | this.el.addEventListener('stateremoved', this.resumeStateBound); 1738 | } else { 1739 | this.el.removeEventListener('stateadded', this.holdStateBound); 1740 | this.el.removeEventListener('stateremoved', this.resumeStateBound); 1741 | } 1742 | }, 1743 | // disble the sleeping during interactions because sleep will break constraints 1744 | holdState: function (evt) { 1745 | let state = this.data.holdState; 1746 | // api change in A-Frame v0.8.0 1747 | if (evt.detail === state || evt.detail.state === state) { 1748 | this.el.body.allowSleep = false; 1749 | } 1750 | }, 1751 | resumeState: function (evt) { 1752 | let state = this.data.holdState; 1753 | if (evt.detail === state || evt.detail.state === state) { 1754 | this.el.body.allowSleep = this.data.allowSleep; 1755 | } 1756 | } 1757 | 1758 | }); 1759 | 1760 | },{}]},{},[1]); 1761 | -------------------------------------------------------------------------------- /examples/collision_response/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A-Frame Physics Extras Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | 50 | 51 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 63 | 64 | 66 | 67 | 68 | 70 | 71 | 73 | 74 | 75 | 78 | 79 | 82 | 83 | 84 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | A-Frame Physics Extras 4 | 61 | 62 | 63 |

A-Frame Physics Extras Example Page

64 |

This is the examples page for the 65 | 66 | aframe-physics-extras 67 | 68 | WebVR package. 69 |

70 | 71 | Collision Physics Settings
72 | 73 |
74 |
75 |

    76 |
  • Open hands can pass through objects
  • 77 |
  • With a pointing gesture (trackpad on Vive wands), hands become 78 | solid and can push objects around
  • 79 |
  • Reach inside objects and use grip or trigger to pick up and move
  • 80 |

81 |

Example of toggling the collisionForces property of 82 | collision-filter to affect whether the controllers have 83 | physical interactions with the environment or not. 84 |

85 | 86 | Merging Physics Bodies
87 |
88 | 89 | 90 | 91 | 92 | 93 | 94 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /examples/main.js: -------------------------------------------------------------------------------- 1 | require('../index.js') 2 | require('aframe-motion-capture-components') 3 | /* used in examples to allow a desktop playback without HMD 4 | defined here to keep example files clear of clutter */ 5 | window.playDemoRecording = function (spectate) { 6 | let l = document.querySelector('a-link, a-entity[link]') 7 | let s = document.querySelector('a-scene') 8 | l && l.setAttribute('visible', 'false') 9 | s.addEventListener('replayingstopped', e => { 10 | let c = document.querySelector('[camera]') 11 | window.setTimeout(function () { 12 | c.setAttribute('position', '0 1.6 2') 13 | c.setAttribute('rotation', '0 0 0') 14 | }) 15 | }) 16 | s.setAttribute('avatar-replayer', { 17 | src: './demo-recording.json', 18 | spectatorMode: spectate === undefined ? true : spectate, 19 | spectatorPosition: {x: 0, y: 1.6, z: 2} 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* global AFRAME */ 2 | 3 | if (typeof AFRAME === 'undefined') { 4 | throw new Error('Component attempted to register before AFRAME was available.') 5 | } 6 | 7 | require('./src/physics-collider.js') 8 | require('./src/physics-collision-filter.js') 9 | require('./src/physics-sleepy.js') 10 | require('./src/body-merger.js') 11 | -------------------------------------------------------------------------------- /machinima_tests/__init.test.js: -------------------------------------------------------------------------------- 1 | /* global sinon, setup, teardown */ 2 | var machinima = require('aframe-machinima-testing') 3 | /** 4 | * __init.test.js is run before every test case. 5 | */ 6 | window.debug = true 7 | 8 | setup(function () { 9 | this.sinon = sinon.sandbox.create() 10 | }) 11 | 12 | teardown(function () { 13 | machinima.teardownReplayer() 14 | // Clean up any attached elements. 15 | const attachedEls = ['canvas', 'a-assets', 'a-scene'] 16 | var els = document.querySelectorAll(attachedEls.join(',')) 17 | 18 | for (var i = 0; i < els.length; i++) { 19 | els[i].parentNode.removeChild(els[i]) 20 | } 21 | this.sinon.restore() 22 | }) 23 | -------------------------------------------------------------------------------- /machinima_tests/assets/blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmurphyrd/aframe-physics-extras/47e46cc9177ce4c5aa6cb27ba124e5861928345f/machinima_tests/assets/blue.png -------------------------------------------------------------------------------- /machinima_tests/assets/green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmurphyrd/aframe-physics-extras/47e46cc9177ce4c5aa6cb27ba124e5861928345f/machinima_tests/assets/green.png -------------------------------------------------------------------------------- /machinima_tests/assets/red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmurphyrd/aframe-physics-extras/47e46cc9177ce4c5aa6cb27ba124e5861928345f/machinima_tests/assets/red.png -------------------------------------------------------------------------------- /machinima_tests/karma.conf.js: -------------------------------------------------------------------------------- 1 | // karma configuration 2 | var karmaConf = { 3 | browserify: { 4 | debug: true 5 | }, 6 | browsers: ['Firefox', 'Chrome'], 7 | // prevent timeout during recording playback 8 | browserNoActivityTimeout: 600000, 9 | client: { 10 | captureConsole: false, 11 | mocha: {'ui': 'tdd'} 12 | }, 13 | files: [ 14 | // module and dependencies 15 | {pattern: 'main.js', included: true}, 16 | // test files. 17 | {pattern: './**/*.test.js'}, 18 | // HTML machinima scenes (pre-processed by html2js) 19 | {pattern: 'scenes/*.html'}, 20 | // machinima recording files (served at base/recordings/) 21 | {pattern: 'recordings/*.json', included: false, served: true}, 22 | // assets 23 | {pattern: 'assets/*.*', included: false, served: true} 24 | ], 25 | frameworks: ['mocha', 'sinon-chai', 'browserify'], 26 | preprocessors: { 27 | 'main.js': ['browserify'], 28 | './**/*.js': ['browserify'], 29 | // process machinima scene files into window.__html__ array 30 | 'scenes/*.html': ['html2js'] 31 | }, 32 | reporters: ['mocha'], 33 | // machinima: make scene html available 34 | html2JsPreprocessor: { 35 | stripPrefix: 'scenes/' 36 | } 37 | } 38 | 39 | // Apply configuration 40 | module.exports = function (config) { 41 | config.set(karmaConf) 42 | } 43 | -------------------------------------------------------------------------------- /machinima_tests/main.js: -------------------------------------------------------------------------------- 1 | window.debug = true 2 | // include all dependencies via require - don't use script tags in scenes 3 | require('aframe') 4 | require('aframe-motion-capture-components') 5 | require('aframe-physics-system') 6 | require('super-hands') 7 | require('aframe-environment-component') 8 | // require your package entry point: 9 | require('../index.js') 10 | -------------------------------------------------------------------------------- /machinima_tests/scenes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Scene Files 5 | 6 | 7 | scene.html 8 | 9 | 10 | -------------------------------------------------------------------------------- /machinima_tests/scenes/scene.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A-Frame Physics Extras Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 35 | 36 | 38 | 39 | 40 | 42 | 43 | 45 | 46 | 47 | 50 | 51 | 54 | 55 | 56 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /machinima_tests/scenes/static.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A-Frame Physics Extras Example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 20 | 21 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 39 | 40 | 41 | 44 | 45 | 48 | 49 | 50 | 54 | 55 | 59 | 60 | 61 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /machinima_tests/tests/component.test.js: -------------------------------------------------------------------------------- 1 | /* global assert, process, setup, suite */ 2 | 3 | const machinima = require('aframe-machinima-testing') 4 | 5 | suite('basic example scene', function () { 6 | setup(function (done) { 7 | this.timeout(0) 8 | machinima.setupScene('scene.html') 9 | this.scene = document.querySelector('a-scene') 10 | this.scene.addEventListener('loaded', e => { 11 | done() 12 | }) 13 | }) 14 | machinima.test( 15 | 'basic component function', 16 | 'base/recordings/physics-extras.json', 17 | function () { 18 | const rh = document.getElementById('redHigh').getAttribute('position') 19 | const gh = document.getElementById('greenHigh').getAttribute('position') 20 | const gl = document.getElementById('greenLow').getAttribute('position') 21 | const bhb = document.getElementById('blueHigh').body 22 | assert.isBelow(rh.x, 0, 'Red upper moved left') 23 | assert.isAbove(rh.x, -2, 'Red upper slept') 24 | assert.deepEqual(gh, {x: -1, y: 1.6, z: -1}, 'Green/red collisions filtered') 25 | assert.isAbove(gl.x, 5, 'Green doesnt sleep') 26 | assert.isAbove(bhb.angularVelocity.length(), 5, 'Blue rotation not dampened') 27 | assert.isBelow(bhb.velocity.length(), 1, 'Blue translation is dampened') 28 | } 29 | ) 30 | }) 31 | suite('static body scene', function () { 32 | setup(function (done) { 33 | machinima.setupScene('static.html') 34 | this.scene = document.querySelector('a-scene') 35 | this.scene.addEventListener('loaded', e => { 36 | done() 37 | }) 38 | }) 39 | machinima.test( 40 | 'physics-collider detects collisions with static bodies', 41 | 'base/recordings/physics-extras.json', 42 | function () { 43 | const rh = document.getElementById('redHigh').getAttribute('material') 44 | const rl = document.getElementById('redLow').getAttribute('material') 45 | const gh = document.getElementById('greenHigh').getAttribute('material') 46 | const gl = document.getElementById('greenLow').getAttribute('material') 47 | const bh = document.getElementById('blueHigh').getAttribute('material') 48 | const bl = document.getElementById('blueLow').getAttribute('material') 49 | assert.isTrue(rh.transparent, 'red high clicked') 50 | assert.isTrue(rl.transparent, 'red low clicked') 51 | assert.isTrue(gl.transparent, 'green low clicked') 52 | assert.isTrue(bh.transparent, 'blue high clicked') 53 | assert.isFalse(bl.transparent, 'blue low not clicked') 54 | assert.isFalse(gh.transparent, 'green high not clicked') 55 | } 56 | ) 57 | }) 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aframe-physics-extras", 3 | "version": "0.1.3", 4 | "description": "Cannon API interface components the A-Frame Physics System.", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "browserify examples/main.js -o examples/build.js -t [ babelify ]", 8 | "dev": "budo examples/main.js:build.js --dir examples --port 8000 --live --open", 9 | "dist": "browserify index.js -o dist/aframe-physics-extras.js -t [ babelify ] && cross-env NODE_ENV=production browserify index.js -o dist/aframe-physics-extras.min.js -t [ babelify ]", 10 | "lint": "standard -v | snazzy", 11 | "prepublish": "npm run dist", 12 | "preghpages": "npm run build && shx rm -rf gh-pages && shx mkdir gh-pages && shx cp -r examples/* gh-pages", 13 | "ghpages": "npm run preghpages && ghpages -p gh-pages", 14 | "start": "npm run dev", 15 | "test": "karma start ./tests/karma.conf.js", 16 | "test:ci": "TEST_ENV=ci karma start ./tests/karma.conf.js --single-run --browsers Firefox", 17 | "test:machinima": "karma start ./machinima_tests/karma.conf.js", 18 | "record:machinima": "budo machinima_tests/main.js:build.js --dir machinima_tests/scenes --port 8000 --live --open" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/wmurphyrd/aframe-physics-extras.git" 23 | }, 24 | "keywords": [ 25 | "aframe", 26 | "aframe-component", 27 | "aframe-vr", 28 | "vr", 29 | "mozvr", 30 | "webvr", 31 | "foo" 32 | ], 33 | "author": "Will Murphy ", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/wmurphyrd/aframe-physics-extras/issues" 37 | }, 38 | "homepage": "https://github.com/wmurphyrd/aframe-physics-extras#readme", 39 | "devDependencies": { 40 | "aframe": "^0.7.0", 41 | "aframe-environment-component": "^1.0.0", 42 | "aframe-machinima-testing": "^0.1.2", 43 | "aframe-motion-capture-components": "git+https://git@github.com/wmurphyrd/aframe-motion-capture-components.git#v0.2.8a", 44 | "aframe-physics-system": "^2.1.0", 45 | "babel-preset-env": "^1.6.0", 46 | "babel-preset-minify": "^0.2.0", 47 | "babelify": "^7.3.0", 48 | "browserify": "^13.0.0", 49 | "budo": "^8.2.2", 50 | "chai": "^4.1.2", 51 | "cross-env": "^5.0.5", 52 | "ghpages": "^0.0.8", 53 | "karma": "^1.7.1", 54 | "karma-browserify": "^5.1.1", 55 | "karma-chrome-launcher": "^2.2.0", 56 | "karma-firefox-launcher": "^1.0.1", 57 | "karma-html2js-preprocessor": "^1.1.0", 58 | "karma-mocha": "^1.3.0", 59 | "karma-mocha-reporter": "^2.1.0", 60 | "karma-sinon-chai": "^1.3.2", 61 | "mocha": "^3.5.3", 62 | "mozilla-download": "^1.1.1", 63 | "randomcolor": "^0.4.4", 64 | "shelljs": "^0.7.0", 65 | "shx": "^0.1.1", 66 | "sinon": "^2.4.1", 67 | "sinon-chai": "^2.14.0", 68 | "snazzy": "^4.0.0", 69 | "standard": "^10.0.3", 70 | "super-hands": "^2.0.2" 71 | }, 72 | "standard": { 73 | "ignore": [ 74 | "examples/build.js", 75 | "dist/**" 76 | ] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /readme_files/physics.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wmurphyrd/aframe-physics-extras/47e46cc9177ce4c5aa6cb27ba124e5861928345f/readme_files/physics.gif -------------------------------------------------------------------------------- /src/body-merger.js: -------------------------------------------------------------------------------- 1 | /* global AFRAME, THREE, CANNON */ 2 | AFRAME.registerComponent('body-merger', { 3 | schema: {default: 'static-body'}, 4 | init: function () { 5 | const doMerge = evt => { 6 | if (evt.target === this.el) { 7 | this.el.removeEventListener('body-loaded', doMerge) 8 | this.merge() 9 | } 10 | } 11 | if (this.el.body) { 12 | this.merge() 13 | } else { 14 | this.el.addEventListener('body-loaded', doMerge) 15 | } 16 | }, 17 | merge: function () { 18 | const body = this.el.body 19 | const tmpMat = new THREE.Matrix4() 20 | const tmpQuat = new THREE.Quaternion() 21 | const tmpPos = new THREE.Vector3() 22 | const tmpScale = new THREE.Vector3(1, 1, 1) // todo: apply worldScale 23 | const offset = new CANNON.Vec3() 24 | const orientation = new CANNON.Quaternion() 25 | for (let child of this.el.childNodes) { 26 | if (!child.body || !child.getAttribute(this.data)) { continue } 27 | child.object3D.updateMatrix() 28 | while (child.body.shapes.length) { 29 | tmpPos.copy(child.body.shapeOffsets.pop()) 30 | tmpQuat.copy(child.body.shapeOrientations.pop()) 31 | tmpMat.compose(tmpPos, tmpQuat, tmpScale) 32 | tmpMat.multiply(child.object3D.matrix) 33 | tmpMat.decompose(tmpPos, tmpQuat, tmpScale) 34 | offset.copy(tmpPos) 35 | orientation.copy(tmpQuat) 36 | body.addShape(child.body.shapes.pop(), offset, orientation) 37 | } 38 | child.removeAttribute(this.data) 39 | } 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /src/physics-collider.js: -------------------------------------------------------------------------------- 1 | /* global AFRAME */ 2 | AFRAME.registerComponent('physics-collider', { 3 | schema: { 4 | ignoreSleep: {default: true} 5 | }, 6 | init: function () { 7 | this.collisions = new Set() 8 | this.currentCollisions = new Set() 9 | this.newCollisions = [] 10 | this.clearedCollisions = [] 11 | this.collisionEventDetails = { 12 | els: this.newCollisions, 13 | clearedEls: this.clearedCollisions 14 | } 15 | }, 16 | update: function () { 17 | if (this.el.body) { 18 | this.updateBody() 19 | } else { 20 | this.el.addEventListener( 21 | 'body-loaded', 22 | this.updateBody.bind(this), 23 | { once: true } 24 | ) 25 | } 26 | }, 27 | tick: (function () { 28 | const uppperMask = 0xFFFF0000 29 | const lowerMask = 0x0000FFFF 30 | return function () { 31 | if (!(this.el.body && this.el.body.world)) return 32 | const currentCollisions = this.currentCollisions 33 | const thisBodyId = this.el.body.id 34 | const worldCollisions = this.el.body.world.bodyOverlapKeeper.current 35 | const worldBodyMap = this.el.body.world.idToBodyMap 36 | const collisions = this.collisions 37 | const newCollisions = this.newCollisions 38 | const clearedCollisions = this.clearedCollisions 39 | let i = 0 40 | let upperId = (worldCollisions[i] & uppperMask) >> 16 41 | let target 42 | newCollisions.length = clearedCollisions.length = 0 43 | currentCollisions.clear() 44 | while (i < worldCollisions.length && upperId < thisBodyId) { 45 | if (worldBodyMap[upperId]) { 46 | target = worldBodyMap[upperId].el 47 | if ((worldCollisions[i] & lowerMask) === thisBodyId) { 48 | currentCollisions.add(target) 49 | if (!collisions.has(target)) { newCollisions.push(target) } 50 | } 51 | } 52 | upperId = (worldCollisions[++i] & uppperMask) >> 16 53 | } 54 | while (i < worldCollisions.length && upperId === thisBodyId) { 55 | if (worldBodyMap[worldCollisions[i] & lowerMask]) { 56 | target = worldBodyMap[worldCollisions[i] & lowerMask].el 57 | currentCollisions.add(target) 58 | if (!collisions.has(target)) { newCollisions.push(target) } 59 | } 60 | upperId = (worldCollisions[++i] & uppperMask) >> 16 61 | } 62 | 63 | for (let col of collisions) { 64 | if (!currentCollisions.has(col)) { 65 | clearedCollisions.push(col) 66 | collisions.delete(col) 67 | } 68 | } 69 | for (let col of newCollisions) { 70 | collisions.add(col) 71 | } 72 | if (newCollisions.length || clearedCollisions.length) { 73 | this.el.emit('collisions', this.collisionEventDetails) 74 | } 75 | } 76 | })(), 77 | remove: function () { 78 | if (this.originalSleepConfig) { 79 | AFRAME.utils.extend(this.el.body, this.originalSleepConfig) 80 | } 81 | }, 82 | updateBody: function (evt) { 83 | // ignore bubbled 'body-loaded' events 84 | if (evt !== undefined && evt.target !== this.el) { return } 85 | if (this.data.ignoreSleep) { 86 | // ensure sleep doesn't disable collision detection 87 | this.el.body.allowSleep = false 88 | /* naiveBroadphase ignores collisions between sleeping & static bodies */ 89 | this.el.body.type = window.CANNON.Body.KINEMATIC 90 | // Kinematics must have velocity >= their sleep limit to wake others 91 | this.el.body.sleepSpeedLimit = 0 92 | } else if (this.originalSleepConfig === undefined) { 93 | this.originalSleepConfig = { 94 | allowSleep: this.el.body.allowSleep, 95 | sleepSpeedLimit: this.el.body.sleepSpeedLimit, 96 | type: this.el.body.type 97 | } 98 | } else { 99 | // restore original settings 100 | AFRAME.utils.extend(this.el.body, this.originalSleepConfig) 101 | } 102 | } 103 | }) 104 | -------------------------------------------------------------------------------- /src/physics-collision-filter.js: -------------------------------------------------------------------------------- 1 | /* global AFRAME */ 2 | AFRAME.registerComponent('collision-filter', { 3 | schema: { 4 | group: {default: 'default'}, 5 | collidesWith: {default: ['default']}, 6 | collisionForces: {default: true} 7 | }, 8 | init: function () { 9 | this.updateBodyBound = this.updateBody.bind(this) 10 | this.system.registerMe(this) 11 | this.el.addEventListener('body-loaded', this.updateBodyBound) 12 | }, 13 | update: function () { 14 | // register any new groups 15 | this.system.registerMe(this) 16 | if (this.el.body) { 17 | this.updateBody() 18 | } 19 | }, 20 | remove: function () { 21 | this.el.removeEventListener('body-loaded', this.updateBodyBound) 22 | }, 23 | updateBody: function (evt) { 24 | // ignore bubbled 'body-loaded' events 25 | if (evt !== undefined && evt.target !== this.el) { return } 26 | this.el.body.collisionFilterMask = 27 | this.system.getFilterCode(this.data.collidesWith) 28 | this.el.body.collisionFilterGroup = 29 | this.system.getFilterCode(this.data.group) 30 | this.el.body.collisionResponse = this.data.collisionForces 31 | } 32 | }) 33 | 34 | AFRAME.registerSystem('collision-filter', { 35 | schema: { 36 | collisionGroups: {default: ['default']} 37 | }, 38 | dependencies: ['physics'], 39 | init: function () { 40 | this.maxGroups = Math.log2(Number.MAX_SAFE_INTEGER) 41 | }, 42 | registerMe: function (comp) { 43 | // add any unknown groups to the master list 44 | const newGroups = [comp.data.group, ...comp.data.collidesWith] 45 | .filter(group => this.data.collisionGroups.indexOf(group) === -1) 46 | this.data.collisionGroups.push(...newGroups) 47 | if (this.data.collisionGroups.length > this.maxGroups) { 48 | throw new Error('Too many collision groups') 49 | } 50 | }, 51 | getFilterCode: function (elGroups) { 52 | let code = 0 53 | if (!Array.isArray(elGroups)) { elGroups = [elGroups] } 54 | // each group corresponds to a bit which is turned on when matched 55 | // floor negates any unmatched groups (2^-1 = 0.5) 56 | elGroups.forEach(group => { 57 | code += Math.floor(Math.pow(2, this.data.collisionGroups.indexOf(group))) 58 | }) 59 | return code 60 | } 61 | }) 62 | -------------------------------------------------------------------------------- /src/physics-sleepy.js: -------------------------------------------------------------------------------- 1 | // Make dynamic bodies idle when not grabbed 2 | /* global AFRAME */ 3 | AFRAME.registerComponent('sleepy', { 4 | schema: { 5 | allowSleep: {default: true}, 6 | speedLimit: {default: 0.25, type: 'number'}, 7 | delay: {default: 0.25, type: 'number'}, 8 | linearDamping: {default: 0.99, type: 'number'}, 9 | angularDamping: {default: 0.99, type: 'number'}, 10 | holdState: {default: 'grabbed'} 11 | }, 12 | init: function () { 13 | this.updateBodyBound = this.updateBody.bind(this) 14 | this.holdStateBound = this.holdState.bind(this) 15 | this.resumeStateBound = this.resumeState.bind(this) 16 | 17 | this.el.addEventListener('body-loaded', this.updateBodyBound) 18 | }, 19 | update: function () { 20 | if (this.el.body) { 21 | this.updateBody() 22 | } 23 | }, 24 | remove: function () { 25 | this.el.removeEventListener('body-loaded', this.updateBodyBound) 26 | this.el.removeEventListener('stateadded', this.holdStateBound) 27 | this.el.removeEventListener('stateremoved', this.resumeStateBound) 28 | }, 29 | updateBody: function (evt) { 30 | // ignore bubbled 'body-loaded' events 31 | if (evt !== undefined && evt.target !== this.el) { return } 32 | if (this.data.allowSleep) { 33 | // only "local" driver compatable 34 | try { 35 | this.el.body.world.allowSleep = true 36 | } catch (err) { 37 | console.error('Unable to activate sleep in physics.' + 38 | '`sleepy` requires "local" physics driver') 39 | } 40 | } 41 | this.el.body.allowSleep = this.data.allowSleep 42 | this.el.body.sleepSpeedLimit = this.data.speedLimit 43 | this.el.body.sleepTimeLimit = this.data.delay 44 | this.el.body.linearDamping = this.data.linearDamping 45 | this.el.body.angularDamping = this.data.angularDamping 46 | if (this.data.allowSleep) { 47 | this.el.addEventListener('stateadded', this.holdStateBound) 48 | this.el.addEventListener('stateremoved', this.resumeStateBound) 49 | } else { 50 | this.el.removeEventListener('stateadded', this.holdStateBound) 51 | this.el.removeEventListener('stateremoved', this.resumeStateBound) 52 | } 53 | }, 54 | // disble the sleeping during interactions because sleep will break constraints 55 | holdState: function (evt) { 56 | let state = this.data.holdState 57 | // api change in A-Frame v0.8.0 58 | if (evt.detail === state || evt.detail.state === state) { 59 | this.el.body.allowSleep = false 60 | } 61 | }, 62 | resumeState: function (evt) { 63 | let state = this.data.holdState 64 | if (evt.detail === state || evt.detail.state === state) { 65 | this.el.body.allowSleep = this.data.allowSleep 66 | } 67 | } 68 | 69 | }) 70 | -------------------------------------------------------------------------------- /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 | var AScene = require('aframe').AScene 8 | 9 | navigator.getVRDisplays = function () { 10 | var resolvePromise = Promise.resolve() 11 | var mockVRDisplay = { 12 | requestPresent: resolvePromise, 13 | exitPresent: resolvePromise, 14 | getPose: function () { return {orientation: null, position: null} }, 15 | requestAnimationFrame: function () { return 1 } 16 | } 17 | return Promise.resolve([mockVRDisplay]) 18 | } 19 | 20 | setup(function () { 21 | this.sinon = sinon.sandbox.create() 22 | // Stubs to not create a WebGL context since Travis CI runs headless. 23 | this.sinon.stub(AScene.prototype, 'render') 24 | this.sinon.stub(AScene.prototype, 'resize') 25 | this.sinon.stub(AScene.prototype, 'setupRenderer') 26 | }) 27 | 28 | teardown(function () { 29 | // Clean up any attached elements. 30 | var attachedEls = ['canvas', 'a-assets', 'a-scene'] 31 | var els = document.querySelectorAll(attachedEls.join(',')) 32 | for (var i = 0; i < els.length; i++) { 33 | els[i].parentNode.removeChild(els[i]) 34 | } 35 | this.sinon.restore() 36 | }) 37 | -------------------------------------------------------------------------------- /tests/components/physics-collider.test.js: -------------------------------------------------------------------------------- 1 | /* global assert, process, setup, suite, test */ 2 | 3 | const helpers = require('../helpers') 4 | const entityFactory = helpers.entityFactory 5 | 6 | suite('physics-collider', function () { 7 | setup(function (done) { 8 | var el = this.el = entityFactory() 9 | window.CANNON = {Body: {KINEMATIC: 4}} 10 | el.body = {el: el, id: 2} 11 | this.scene = el.sceneEl 12 | this.el.setAttribute('physics-collider', '') 13 | this.target1 = document.createElement('a-entity') 14 | this.scene.appendChild(this.target1) 15 | this.target1.body = {el: this.target1, id: 1} 16 | this.target2 = document.createElement('a-entity') 17 | this.scene.appendChild(this.target2) 18 | this.target2.body = {el: this.target2, id: 3} 19 | this.scene.addEventListener('loaded', () => { 20 | this.comp = this.el.components['physics-collider'] 21 | done() 22 | }) 23 | }) 24 | suite('lifecyle', function () { 25 | test('component attaches and removes without errors', function (done) { 26 | this.el.removeAttribute('physics-collider') 27 | process.nextTick(done) 28 | }) 29 | }) 30 | suite('collisions', function () { 31 | test('finds collided entities in contacts array', function () { 32 | const hitSpy = this.sinon.spy() 33 | this.el.addEventListener('collisions', hitSpy) 34 | this.el.body.world = { 35 | bodyOverlapKeeper: {current: [ 36 | (this.target1.body.id << 16) + this.el.body.id, 37 | (this.el.body.id << 16) + this.target2.body.id 38 | ]}, 39 | idToBodyMap: [undefined, this.target1.body, this.el.body, this.target2.body] 40 | } 41 | this.comp.tick() 42 | assert.isTrue( 43 | hitSpy.calledWithMatch({detail: {els: [this.target1, this.target2]}}), 44 | 'finds new collisions' 45 | ) 46 | this.comp.tick() 47 | assert.strictEqual(this.comp.collisions.size, 2, 'ignores duplicates') 48 | this.el.body.world.bodyOverlapKeeper.current.pop() 49 | this.comp.tick() 50 | assert.isTrue( 51 | hitSpy.calledWithMatch({detail: {els: [], clearedEls: [this.target2]}}), 52 | 'clears old collisions and ignores duplicates' 53 | ) 54 | assert.strictEqual(this.comp.collisions.size, 1, 'keeps ongoing collisions') 55 | assert.isTrue(this.comp.collisions.has(this.target1), 'keeps ongoing collisions') 56 | }) 57 | test('Handles bodies removed while collided', function () { 58 | this.el.body.world = { 59 | bodyOverlapKeeper: {current: [ 60 | (this.target1.body.id << 16) + this.el.body.id, 61 | (this.el.body.id << 16) + this.target2.body.id 62 | ]}, 63 | idToBodyMap: [undefined, this.target1.body, this.el.body, this.target2.body] 64 | } 65 | this.comp.tick() 66 | this.el.body.world.idToBodyMap[3] = undefined 67 | this.comp.tick() 68 | assert.isFalse(this.comp.collisions.has(this.target2), 'lower loop') 69 | this.el.body.world.idToBodyMap[1] = undefined 70 | this.comp.tick() 71 | assert.isFalse(this.comp.collisions.has(this.target1), 'upper loop') 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /tests/components/physics-collision-filter.test.js: -------------------------------------------------------------------------------- 1 | /* global assert, process, setup, suite, test */ 2 | 3 | const helpers = require('../helpers') 4 | const entityFactory = helpers.entityFactory 5 | 6 | suite('collision-filter', function () { 7 | setup(function (done) { 8 | var el = this.el = entityFactory() 9 | el.body = {el: el} 10 | this.scene = el.sceneEl 11 | this.el.setAttribute('collision-filter', '') 12 | this.target1 = document.createElement('a-entity') 13 | this.scene.appendChild(this.target1) 14 | this.target1.setAttribute('collision-filter', '') 15 | this.target2 = document.createElement('a-entity') 16 | this.scene.appendChild(this.target2) 17 | this.target2.setAttribute('collision-filter', '') 18 | this.scene.addEventListener('loaded', () => { 19 | this.comp = this.el.components['collision-filter'] 20 | this.system = this.comp.system 21 | done() 22 | }) 23 | }) 24 | suite('lifecyle', function () { 25 | test('component attaches and removes without errors', function (done) { 26 | this.el.removeAttribute('collision-filter') 27 | process.nextTick(done) 28 | }) 29 | }) 30 | suite('filter codes', function () { 31 | test('returns unique bit code for each group', function () { 32 | assert.strictEqual(this.system.getFilterCode('default'), 1) 33 | this.el.setAttribute('collision-filter', {group: 'group1'}) 34 | this.el.setAttribute('collision-filter', { 35 | group: 'group1', 36 | collidesWith: ['group2', 'group3'] 37 | }) 38 | assert.strictEqual(this.system.getFilterCode('group1'), 2) 39 | assert.strictEqual(this.system.getFilterCode('group2'), 4) 40 | assert.strictEqual(this.system.getFilterCode('group3'), 8) 41 | }) 42 | test('adds filter codes', function () { 43 | this.el.setAttribute('collision-filter', { 44 | collidesWith: ['group1', 'group2'] 45 | }) 46 | assert.strictEqual(this.system.getFilterCode(['group1', 'group2']), 6) 47 | assert.strictEqual(this.system.getFilterCode(['default', 'group2']), 5) 48 | }) 49 | test('sets filter masks on body', function () { 50 | this.el.setAttribute('collision-filter', {group: 'group1'}) 51 | this.el.body = {} 52 | this.el.emit('body-loaded') 53 | assert.strictEqual(this.el.body.collisionFilterGroup, 2) 54 | this.el.setAttribute('collision-filter', { 55 | collidesWith: ['group2', 'group3'] 56 | }) 57 | assert.strictEqual(this.el.body.collisionFilterMask, 12) 58 | }) 59 | }) 60 | suite('settings', function () { 61 | test('collisionForces can be disabled', function () { 62 | assert.isTrue(this.el.body.collisionResponse) 63 | this.el.setAttribute('collision-filter', {collisionForces: false}) 64 | assert.isFalse(this.el.body.collisionResponse) 65 | }) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /tests/components/physics-sleepy.test.js: -------------------------------------------------------------------------------- 1 | /* global assert, process, setup, suite, test */ 2 | 3 | const helpers = require('../helpers') 4 | const entityFactory = helpers.entityFactory 5 | 6 | suite('sleepy', function () { 7 | setup(function (done) { 8 | var el = this.el = entityFactory() 9 | this.scene = el.sceneEl 10 | this.el.setAttribute('sleepy', '') 11 | this.scene.addEventListener('loaded', () => { 12 | this.comp = this.el.components['sleepy'] 13 | done() 14 | }) 15 | }) 16 | suite('lifecyle', function () { 17 | test('component attaches and removes without errors', function (done) { 18 | this.el.removeAttribute('sleepy') 19 | process.nextTick(done) 20 | }) 21 | }) 22 | suite('applies settings', function () { 23 | test('initial settings applied to body loaded later', function () { 24 | this.el.body = {world: {}} 25 | this.el.emit('body-loaded') 26 | assert.isTrue(this.el.body.allowSleep) 27 | assert.isTrue(this.el.body.world.allowSleep) 28 | assert.strictEqual(this.el.body.sleepSpeedLimit, 0.25) 29 | assert.strictEqual(this.el.body.sleepTimeLimit, 0.25) 30 | assert.strictEqual(this.el.body.linearDamping, 0.99) 31 | assert.strictEqual(this.el.body.angularDamping, 0.99) 32 | }) 33 | test('updates applied to existing body', function () { 34 | this.el.body = {world: {}} 35 | this.el.setAttribute('sleepy', { 36 | allowSleep: false, 37 | speedLimit: 1, 38 | delay: 1, 39 | linearDamping: 0, 40 | angularDamping: 0 41 | }) 42 | assert.strictEqual(this.el.body.sleepSpeedLimit, 1) 43 | assert.strictEqual(this.el.body.sleepTimeLimit, 1) 44 | assert.strictEqual(this.el.body.linearDamping, 0) 45 | assert.strictEqual(this.el.body.angularDamping, 0) 46 | assert.isFalse(this.el.body.allowSleep) 47 | }) 48 | }) 49 | suite('hold state', function () { 50 | test('turns sleep on and off with grabbed state', function () { 51 | this.el.body = {world: {}} 52 | this.el.emit('body-loaded') 53 | assert.isTrue(this.el.body.allowSleep) 54 | this.el.emit('stateadded', {state: 'grabbed'}) 55 | assert.isFalse(this.el.body.allowSleep) 56 | this.el.emit('stateremoved', {state: 'grabbed'}) 57 | assert.isTrue(this.el.body.allowSleep) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /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 (opts, usePhysics) { 10 | var scene = document.createElement('a-scene') 11 | var assets = document.createElement('a-assets') 12 | var entity = document.createElement('a-entity') 13 | scene.appendChild(assets) 14 | scene.appendChild(entity) 15 | if (usePhysics) { scene.setAttribute('physics', '') } 16 | opts = opts || {} 17 | 18 | if (opts.assets) { 19 | opts.assets.forEach(function (asset) { 20 | assets.appendChild(asset) 21 | }) 22 | } 23 | 24 | document.body.appendChild(scene) 25 | // convenience link to scene because new entities in FF don't get .sceneEl until loaded 26 | entity.sceneEl = scene 27 | return entity 28 | } 29 | 30 | /** 31 | * Creates and attaches a mixin element (and an `` element if necessary). 32 | * 33 | * @param {string} id - ID of mixin. 34 | * @param {object} obj - Map of component names to attribute values. 35 | * @param {Element} scene - Indicate which scene to apply mixin to if necessary. 36 | * @returns {object} An attached `` element. 37 | */ 38 | module.exports.mixinFactory = function (id, obj, scene) { 39 | var mixinEl = document.createElement('a-mixin') 40 | mixinEl.setAttribute('id', id) 41 | Object.keys(obj).forEach(function (componentName) { 42 | mixinEl.setAttribute(componentName, obj[componentName]) 43 | }) 44 | 45 | var assetsEl = scene ? scene.querySelector('a-assets') : document.querySelector('a-assets') 46 | assetsEl.appendChild(mixinEl) 47 | 48 | return mixinEl 49 | } 50 | 51 | /** 52 | * Test that is only run locally and is skipped on CI. 53 | */ 54 | module.exports.getSkipCISuite = function () { 55 | if (window.__env__.TEST_ENV === 'ci') { 56 | return suite.skip 57 | } else { 58 | return suite 59 | } 60 | } 61 | 62 | /** 63 | * Creates and attaches a hand controller entity with a control component 64 | * 65 | * @param {object} comps - Map of component names to attribute values. 66 | * @param {Element} scene - Indicate which scene to apply mixin to if necessary. 67 | * @returns {bool} controllerOverride - Set true if comps already contains a controller component and does not need the default added. 68 | */ 69 | module.exports.controllerFactory = function (comps, controllerOverride, scene) { 70 | var contrEl = document.createElement('a-entity') 71 | comps = comps || {} 72 | if (!controllerOverride) { 73 | comps['vive-controls'] = 'hand: right' 74 | } 75 | Object.keys(comps).forEach(function (componentName) { 76 | contrEl.setAttribute(componentName, comps[componentName]) 77 | }) 78 | scene = scene || document.querySelector('a-scene') 79 | scene.appendChild(contrEl) 80 | return contrEl 81 | } 82 | 83 | module.exports.emitCancelable = function (target, name, detail) { 84 | const data = {bubbles: true, cancelable: true, detail: detail || {}} 85 | let evt 86 | data.detail.target = data.detail.target || target 87 | evt = new window.CustomEvent(name, data) 88 | return target.dispatchEvent(evt) 89 | } 90 | -------------------------------------------------------------------------------- /tests/karma.conf.js: -------------------------------------------------------------------------------- 1 | // karma configuration 2 | var karmaConf = { 3 | basePath: '../', 4 | browserify: { 5 | debug: true // , 6 | // transform: [ 7 | // ['babelify', {presets: ['es2015']}] 8 | // ] 9 | }, 10 | browsers: ['Chrome', 'Firefox'], 11 | // browsers: ['FirefoxNightly', 'Chromium_WebVR'], 12 | client: { 13 | captureConsole: true, 14 | mocha: {'ui': 'tdd'} 15 | }, 16 | customLaunchers: { 17 | Chromium_WebVR: { 18 | base: 'Chromium', 19 | flags: ['--enable-webvr', '--enable-gamepad-extensions'] 20 | } 21 | }, 22 | envPreprocessor: [ 23 | 'TEST_ENV' 24 | ], 25 | files: [ 26 | // dependencies 27 | {pattern: 'tests/testDependencies.js', included: true}, 28 | // module 29 | {pattern: 'index.js', included: true}, 30 | // Define test files. 31 | {pattern: 'tests/**/*.test.js'} 32 | // Serve test assets. 33 | // {pattern: 'tests/assets/**/*', included: false, served: true} 34 | ], 35 | frameworks: ['mocha', 'sinon-chai', 'browserify'], 36 | preprocessors: { 37 | 'tests/testDependencies.js': ['browserify'], 38 | 'index.js': ['browserify'], 39 | 'tests/**/*.js': ['browserify'] 40 | }, 41 | reporters: ['mocha'] 42 | } 43 | 44 | // Apply configuration 45 | module.exports = function (config) { 46 | config.set(karmaConf) 47 | } 48 | -------------------------------------------------------------------------------- /tests/testDependencies.js: -------------------------------------------------------------------------------- 1 | window.debug = true 2 | require('aframe') 3 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | module: { 3 | rules: [{ 4 | test: /\.js$/, 5 | exclude: function (modulePath) { 6 | return /node_modules/.test(modulePath) && 7 | !/src.node_modules/.test(modulePath) 8 | }, 9 | use: [ 10 | { 11 | loader: 'babel-loader' 12 | } 13 | ] 14 | }] 15 | } 16 | } 17 | --------------------------------------------------------------------------------