Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 93 additions & 51 deletions src/components/raycaster.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
/* global MutationObserver */

var registerComponent = require('../core/component').registerComponent;
var THREE = require('../lib/three');
var utils = require('../utils/');

var bind = utils.bind;
var warn = utils.debug('components:raycaster:warn');

var dummyVec = new THREE.Vector3();

// Defines selectors that should be 'safe' for the MutationObserver used to
// refresh the whitelist. Matches classnames, IDs, and presence of attributes.
// Selectors for the value of an attribute, like [position=0 2 0], cannot be
// reliably detected and are therefore disallowed.
var OBSERVER_SELECTOR_RE = /^[\w\s-.,[\]#]*$/;

// Configuration for the MutationObserver used to refresh the whitelist.
// Listens for addition/removal of elements and attributes within the scene.
var OBSERVER_CONFIG = {
childList: true,
attributes: true,
subtree: true
};

/**
* Raycaster component.
*
Expand All @@ -28,7 +44,8 @@ module.exports.Component = registerComponent('raycaster', {
origin: {type: 'vec3'},
recursive: {default: true},
showLine: {default: false},
useWorldCoordinates: {default: false}
useWorldCoordinates: {default: false},
autoRefresh: {default: true}
},

init: function () {
Expand All @@ -38,13 +55,14 @@ module.exports.Component = registerComponent('raycaster', {
this.lineEndVec3 = new THREE.Vector3();
this.unitLineEndVec3 = new THREE.Vector3();
this.intersectedEls = [];
this.objects = null;
this.objects = [];
this.prevCheckTime = undefined;
this.prevIntersectedEls = [];
this.raycaster = new THREE.Raycaster();
this.updateOriginDirection();
this.refreshObjects = bind(this.refreshObjects, this);
this.refreshOnceChildLoaded = bind(this.refreshOnceChildLoaded, this);
this.setDirty = this.setDirty.bind(this);
this.observer = new MutationObserver(this.setDirty);
this.dirty = true;
},

/**
Expand All @@ -70,19 +88,25 @@ module.exports.Component = registerComponent('raycaster', {
el.removeAttribute('line');
}

this.refreshObjects();
if (data.objects !== oldData.objects && !OBSERVER_SELECTOR_RE.test(data.objects)) {
warn('Selector "' + data.objects + '" may not update automatically with DOM changes.');
}

if (data.autoRefresh !== oldData.autoRefresh && el.isPlaying) {
data.autoRefresh
? this.addEventListeners()
: this.removeEventListeners();
}

this.setDirty();
},

play: function () {
this.el.sceneEl.addEventListener('loaded', this.refreshObjects);
this.el.sceneEl.addEventListener('child-attached', this.refreshOnceChildLoaded);
this.el.sceneEl.addEventListener('child-detached', this.refreshObjects);
this.addEventListeners();
},

pause: function () {
this.el.sceneEl.removeEventListener('loaded', this.refreshObjects);
this.el.sceneEl.removeEventListener('child-attached', this.refreshOnceChildLoaded);
this.el.sceneEl.removeEventListener('child-detached', this.refreshObjects);
this.removeEventListeners();
},

remove: function () {
Expand All @@ -91,54 +115,37 @@ module.exports.Component = registerComponent('raycaster', {
}
},

addEventListeners: function () {
if (!this.data.autoRefresh) { return; }
this.observer.observe(this.el.sceneEl, OBSERVER_CONFIG);
this.el.sceneEl.addEventListener('object3dset', this.setDirty);
this.el.sceneEl.addEventListener('object3dremove', this.setDirty);
},

removeEventListeners: function () {
this.observer.disconnect();
this.el.sceneEl.removeEventListener('object3dset', this.setDirty);
this.el.sceneEl.removeEventListener('object3dremove', this.setDirty);
},

/**
* Update list of objects to test for intersection once child is loaded.
* Mark the object list as dirty, to be refreshed before next raycast.
*/
refreshOnceChildLoaded: function (evt) {
var self = this;
var childEl = evt.detail.el;
if (!childEl) { return; }
if (childEl.hasLoaded) {
this.refreshObjects();
} else {
childEl.addEventListener('loaded', function nowRefresh (evt) {
childEl.removeEventListener('loaded', nowRefresh);
self.refreshObjects();
});
}
setDirty: function () {
this.dirty = true;
},

/**
* Update list of objects to test for intersection.
*/
refreshObjects: function () {
var children;
var data = this.data;
var i;
var objects;
// Target entities.
var targetEls = data.objects ? this.el.sceneEl.querySelectorAll(data.objects) : null;

// Push meshes onto list of objects to intersect.
if (targetEls) {
objects = [];
for (i = 0; i < targetEls.length; i++) {
objects.push(targetEls[i].object3D);
}
} else {
// If objects not defined, intersect with everything.
objects = this.el.sceneEl.object3D.children;
}

this.objects = [];
for (i = 0; i < objects.length; i++) {
// A-Frame wraps everything in THREE.Group. Grab the children.
children = objects[i].children;

// Add the object3D children for non-recursive raycasting.
// If no children, refresh after entity loaded.
if (children) { this.objects.push.apply(this.objects, children); }
}
// If objects not defined, intersect with everything.
var els = data.objects
? this.el.sceneEl.querySelectorAll(data.objects)
: this.el.sceneEl.children;
this.objects = flattenChildrenShallow(els);
this.dirty = false;
},

/**
Expand All @@ -163,6 +170,9 @@ module.exports.Component = registerComponent('raycaster', {
// Update check time.
this.prevCheckTime = time;

// Refresh the object whitelist if needed.
if (this.dirty) { this.refreshObjects(); }

// Store old previously intersected entities.
copyArray(this.prevIntersectedEls, this.intersectedEls);

Expand Down Expand Up @@ -299,6 +309,38 @@ module.exports.Component = registerComponent('raycaster', {
})()
});

/**
* Returns children of each element's object3D group. Children are flattened
* by one level, removing the THREE.Group wrapper, so that non-recursive
* raycasting remains useful.
*
* @param {Array<Element>} els
* @return {Array<THREE.Object3D>}
*/
function flattenChildrenShallow (els) {
var groups = [];
var objects = [];
var children;
var i;

// Push meshes onto list of objects to intersect.
for (i = 0; i < els.length; i++) {
if (els[i].object3D) {
groups.push(els[i].object3D);
}
}

// Each entity's root is a THREE.Group. Return the group's chilrden.
for (i = 0; i < groups.length; i++) {
children = groups[i].children;
if (children && children.length) {
objects.push.apply(objects, children);
}
}

return objects;
}

/**
* Copy contents of one array to another without allocating new array.
*/
Expand Down
49 changes: 40 additions & 9 deletions tests/components/raycaster.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ suite('raycaster', function () {
el2.setAttribute('geometry', 'primitive: box');
el2.addEventListener('loaded', function () {
el.setAttribute('raycaster', 'objects', '.clickable');
component.tick();
assert.equal(component.objects.length, 1);
assert.equal(component.objects[0], el2.object3D.children[0]);
assert.equal(el2, el2.object3D.children[0].el);
Expand Down Expand Up @@ -115,33 +116,63 @@ suite('raycaster', function () {

test('refresh objects when new entities are added to the scene', function (done) {
var newEl = document.createElement('a-entity');
component.tick();
var numObjects = component.objects.length;
newEl.setAttribute('geometry', 'primitive: box');
newEl.addEventListener('loaded', function () {
setTimeout(() => {
assert.equal(component.objects.length, numObjects + 1);
done();
});
component.tick();
assert.equal(component.objects.length, numObjects + 1);
done();
});
sceneEl.appendChild(newEl);
});

test('refresh objects when new entities are removed from the scene', function (done) {
var newEl = document.createElement('a-entity');
component.tick();
var numObjects = component.objects.length;
newEl.setAttribute('geometry', 'primitive: box');
sceneEl.addEventListener('child-detached', function doAssert () {
setTimeout(() => {
assert.equal(component.objects.length, numObjects);
sceneEl.removeEventListener('child-detached', doAssert);
done();
});
component.tick();
assert.equal(component.objects.length, numObjects);
sceneEl.removeEventListener('child-detached', doAssert);
done();
});
newEl.addEventListener('loaded', function () {
sceneEl.removeChild(newEl);
});
sceneEl.appendChild(newEl);
});

test('refresh objects when entities are modified', function (done) {
el.setAttribute('raycaster', {objects: '[ray-target]'});
var newEl = document.createElement('a-entity');
newEl.setAttribute('geometry', 'primitive: box');
newEl.addEventListener('loaded', function doAssert () {
component.tick();
assert.equal(component.objects.length, 0);
newEl.setAttribute('ray-target', '');
setTimeout(function () {
component.tick();
assert.equal(component.objects.length, 1);
sceneEl.removeEventListener('child-attached', doAssert);
done();
}, 0);
});
sceneEl.appendChild(newEl);
});

test('refresh objects when setObject3D() or removeObject3D() is called', function () {
el.setAttribute('raycaster', {objects: '[ray-target]'});
component.tick();
assert.equal(component.dirty, false);
sceneEl.emit('object3dset');
assert.equal(component.dirty, true);
component.tick();
assert.equal(component.dirty, false);
sceneEl.emit('object3dremove');
assert.equal(component.dirty, true);
});
});

suite('raycaster', function () {
Expand Down