three.ar.js
Version:
A helper three.js library for building AR web experiences that run in WebARonARKit and WebARonARCore
552 lines (483 loc) • 14.6 kB
JavaScript
/*
* Copyright 2017 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ARPlanes from './ARPlanes';
const DEFAULTS = {
open: true,
showLastHit: true,
showPoseStatus: true,
showPlanes: false,
};
const SUCCESS_COLOR = '#00ff00';
const FAILURE_COLOR = '#ff0077';
const PLANES_POLLING_TIMER = 500;
const THROTTLE_SPEED = 500;
// A cache to store original native VRDisplay methods
// since WebARonARKit does not provide a VRDisplay.prototype[method],
// and assuming the first time ARDebug proxies a method is the
// 'native' version, this caches the correct method if we proxy a method twice
let cachedVRDisplayMethods = new Map();
/**
* A throttle function to limit number of DOM writes
* in the ARDebug view.
*
* @param {Function} fn
* @param {number} timer
* @param {Object} scope
*
* @return {Function}
*/
function throttle(fn, timer, scope) {
let lastFired;
let timeout;
return (...args) => {
const current = +new Date();
let until;
if (lastFired) {
until = lastFired + timer - current;
}
if (until == undefined || until < 0) {
lastFired = current;
fn.apply(scope, args);
} else if (until >= 0) {
clearTimeout(timeout);
timeout = setTimeout(() => {
lastFired = current;
fn.apply(scope, args);
}, until);
}
};
}
/**
* Class for creating a mesh that fires raycasts and lerps
* a 3D object along the surface
*/
class ARDebug {
/**
* @param {VRDisplay} vrDisplay
* @param {THREE.Scene?} scene
* @param {Object} config
* @param {boolean} config.open
* @param {boolean} config.showLastHit
* @param {boolean} config.showPoseStatus
* @param {boolean} config.showPlanes
*/
constructor(vrDisplay, scene, config) {
// Make `scene` optional
if (typeof config === 'undefined' && scene && scene.type !== 'Scene') {
config = scene;
scene = null;
}
this.config = Object.assign({}, DEFAULTS, config);
this.vrDisplay = vrDisplay;
this._view = new ARDebugView({ open: this.config.open });
if (this.config.showLastHit && this.vrDisplay.hitTest) {
this._view.addRow('hit-test', new ARDebugHitTestRow(vrDisplay));
}
if (this.config.showPoseStatus && this.vrDisplay.getFrameData) {
this._view.addRow('pose-status', new ARDebugPoseRow(vrDisplay));
}
if (this.config.showPlanes && this.vrDisplay.getPlanes) {
if (!scene) {
console.warn('ARDebug `{ showPlanes: true }` option requires ' +
'passing in a THREE.Scene as the second parameter ' +
'in the constructor.');
} else {
this._view.addRow('show-planes', new ARDebugPlanesRow(vrDisplay, scene));
}
}
}
/**
* Opens the debug panel.
*/
open() {
this._view.open();
}
/**
* Closes the debug panel.
*/
close() {
this._view.close();
}
/**
* Returns the root DOM element for the panel.
*
* @return {HTMLElement}
*/
getElement() {
return this._view.getElement();
}
}
/**
* An implementation that interfaces with the DOM, used
* by ARDebug
*/
class ARDebugView {
/**
* @param {Object} config
* @param {boolean} config.open
*/
constructor(config = {}) {
this.rows = new Map();
this.el = document.createElement('div');
this.el.style.backgroundColor = '#333';
this.el.style.padding = '5px';
this.el.style.fontFamily = 'Roboto, Ubuntu, Arial, sans-serif';
this.el.style.color = 'rgb(165, 165, 165)';
this.el.style.position = 'absolute';
this.el.style.right = '20px';
this.el.style.top = '0px';
this.el.style.width = '200px';
this.el.style.fontSize = '12px';
this.el.style.zIndex = 9999;
this._rowsEl = document.createElement('div');
this._rowsEl.style.transitionProperty = 'max-height';
this._rowsEl.style.transitionDuration = '0.5s';
this._rowsEl.style.transitionDelay = '0s';
this._rowsEl.style.transitionTimingFunction = 'ease-out';
this._rowsEl.style.overflow = 'hidden';
this._controls = document.createElement('div');
this._controls.style.fontSize = '13px';
this._controls.style.fontWeight = 'bold';
this._controls.style.paddingTop = '5px';
this._controls.style.textAlign = 'center';
this._controls.style.cursor = 'pointer';
this._controls.addEventListener('click', this.toggleControls.bind(this));
// Initialize the view as open or closed
config.open ? this.open() : this.close();
this.el.appendChild(this._rowsEl);
this.el.appendChild(this._controls);
}
/**
* Toggles between open and close modes.
*/
toggleControls() {
if (this._isOpen) {
this.close();
} else {
this.open();
}
}
/**
* Opens the debugging panel.
*/
open() {
// Use max-height with large value to transition
// to/from a non-specific height (like auto/100%)
// https://stackoverflow.com/a/8331169
// @TODO investigate a more complete solution with correct timing,
// via something like http://n12v.com/css-transition-to-from-auto/
this._rowsEl.style.maxHeight = '100px';
this._isOpen = true;
this._controls.textContent = 'Close ARDebug';
for (let [, row] of this.rows) {
row.enable();
}
}
/**
* Closes the debugging panel.
*/
close() {
this._rowsEl.style.maxHeight = '0px';
this._isOpen = false;
this._controls.textContent = 'Open ARDebug';
for (let [, row] of this.rows) {
row.disable();
}
}
/**
* Returns the ARDebugView root element.
*
* @return {HTMLElement}
*/
getElement() {
return this.el;
}
/**
* Adds a row to the ARDebugView.
*
* @param {string} id
* @param {ARDebugRow} row
*/
addRow(id, row) {
this.rows.set(id, row);
if (this._isOpen) {
row.enable();
}
this._rowsEl.appendChild(row.getElement());
}
}
/**
* A class that implements features being a row in the ARDebugView.
*/
class ARDebugRow {
/**
* @param {string} title
*/
constructor(title) {
this.el = document.createElement('div');
this.el.style.width = '100%';
this.el.style.borderTop = '1px solid rgb(54, 54, 54)';
this.el.style.borderBottom = '1px solid #14171A';
this.el.style.position = 'relative';
this.el.style.padding = '3px 0px';
this.el.style.overflow = 'hidden';
this._titleEl = document.createElement('span');
this._titleEl.style.fontWeight = 'bold';
this._titleEl.textContent = title;
this._dataEl = document.createElement('span');
this._dataEl.style.position = 'absolute';
this._dataEl.style.left = '40px';
// Create a text element to update so we can avoid
// forced reflows when updating
// https://stackoverflow.com/a/17203046
this._dataElText = document.createTextNode('');
this._dataEl.appendChild(this._dataElText);
this.el.appendChild(this._titleEl);
this.el.appendChild(this._dataEl);
this._throttledWriteToDOM = throttle(this._writeToDOM, THROTTLE_SPEED, this);
}
/**
* Enables the proxying and inspection functionality of
* this row. Should be implemented by child class.
*/
enable() {
throw new Error('Implement in child class');
}
/**
* Disables the proxying and inspection functionality of
* this row. Should be implemented by child class.
*/
disable() {
throw new Error('Implement in child class');
}
/**
* Returns the ARDebugRow's root element.
*
* @return {HTMLElement}
*/
getElement() {
return this.el;
}
/**
* Updates the row's value. Can be marked to write immediately to render
* now versus at a throttled rate, for instance on a state change
* that may be rendered in the future (like slight changes in position).
*
* @param {string} value
* @param {boolean} isSuccess
* @param {boolean} renderImmediately
*/
update(value, isSuccess, renderImmediately) {
if (renderImmediately) {
this._writeToDOM(value, isSuccess);
} else {
this._throttledWriteToDOM(value, isSuccess);
}
}
/**
* Underlying function called by `update` that does the DOM
* changes.
*
* @param {string} value
* @param {boolean} isSuccess
*/
_writeToDOM(value, isSuccess) {
this._dataElText.nodeValue = value;
this._dataEl.style.color = isSuccess ? SUCCESS_COLOR : FAILURE_COLOR;
}
}
/**
* The ARDebugRow subclass for displaying hit information
* by wrapping `vrDisplay.hitTest` and displaying the results.
*/
class ARDebugHitTestRow extends ARDebugRow {
/**
* @param {VRDisplay} vrDisplay
*/
constructor(vrDisplay) {
super('Hit');
this.vrDisplay = vrDisplay;
this._onHitTest = this._onHitTest.bind(this);
// Store the native hit test, or proxy the native `hitTest` call with our own
this._nativeHitTest = cachedVRDisplayMethods.get('hitTest') || this.vrDisplay.hitTest;
cachedVRDisplayMethods.set('hitTest', this._nativeHitTest);
this._didPreviouslyHit = null;
}
/**
* Enables the tracking of hit test information.
*/
enable() {
this.vrDisplay.hitTest = this._onHitTest;
}
/**
* Disables the tracking of hit test information.
*/
disable() {
this.vrDisplay.hitTest = this._nativeHitTest;
}
/**
* @param {VRHit} hit
* @return {string}
*/
_hitToString(hit) {
const mm = hit.modelMatrix;
return `${mm[12].toFixed(2)}, ${mm[13].toFixed(2)}, ${mm[14].toFixed(2)}`;
}
/**
* @param {number} x
* @param {number} y
* @return {VRHit?}
*/
_onHitTest(x, y) {
const hits = this._nativeHitTest.call(this.vrDisplay, x, y);
const t = (parseInt(performance.now(), 10) / 1000).toFixed(1);
const didHit = hits && hits.length;
const value = `${didHit ? this._hitToString(hits[0]) : 'MISS'} @ ${t}s`;
this.update(value, didHit, didHit !== this._didPreviouslyHit);
this._didPreviouslyHit = didHit;
return hits;
}
}
/**
* The ARDebugRow subclass for displaying pose information
* by wrapping `vrDisplay.getFrameData` and displaying the results.
*/
class ARDebugPoseRow extends ARDebugRow {
/**
* @param {VRDisplay} vrDisplay
*/
constructor(vrDisplay) {
super('Pose');
this.vrDisplay = vrDisplay;
this._onGetFrameData = this._onGetFrameData.bind(this);
// Store the native hit test, or proxy the native `hitTest` call with our own
this._nativeGetFrameData = cachedVRDisplayMethods.get('getFrameData') ||
this.vrDisplay.getFrameData;
cachedVRDisplayMethods.set('getFrameData', this._nativeGetFrameData);
this.update('Looking for position...', false, true);
this._initialPose = false;
}
/**
* Enables displaying and pulling getFrameData
*/
enable() {
this.vrDisplay.getFrameData = this._onGetFrameData;
}
/**
* Disables displaying and pulling getFrameData
*/
disable() {
this.vrDisplay.getFrameData = this._nativeGetFrameData;
}
/**
* @param {VRPose} pose
* @return {string}
*/
_poseToString(pose) {
return `${pose[0].toFixed(2)}, ${pose[1].toFixed(2)}, ${pose[2].toFixed(2)}`;
}
/**
* Wrapper around getFrameData
*
* @param {VRFrameData} frameData
* @return {boolean}
*/
_onGetFrameData(frameData) {
const results = this._nativeGetFrameData.call(this.vrDisplay, frameData);
const pose = frameData && frameData.pose && frameData.pose.position;
// Ensure we have a valid pose; while the pose SHOULD be null when not
// provided by the VRDisplay, on WebARonARCore, the xyz values of position
// are all 0 -- mark this as an invalid pose
const isValidPose = pose &&
typeof pose[0] === 'number' &&
typeof pose[1] === 'number' &&
typeof pose[2] === 'number' &&
!(pose[0] === 0 && pose[1] === 0 && pose[2] === 0);
// If we haven't received a pose yet, and still don't have a valid pose
// leave the message how it is
if (!this._initialPose && !isValidPose) {
return results;
}
const renderImmediately = isValidPose !== this._lastPoseValid;
if (isValidPose) {
this.update(this._poseToString(pose), true, renderImmediately);
} else if (!isValidPose && this._lastPoseValid !== false) {
this.update(`Position lost`, false, renderImmediately);
}
this._lastPoseValid = isValidPose;
this._initialPose = true;
return results;
}
}
/**
* The ARDebugRow subclass for displaying planes information
* by wrapping polling getPlanes, and rendering.
*/
class ARDebugPlanesRow extends ARDebugRow {
/**
* @param {VRDisplay} vrDisplay
* @param {THREE.Scene} scene
*/
constructor(vrDisplay, scene) {
super('Planes');
this.vrDisplay = vrDisplay;
this.planes = new ARPlanes(this.vrDisplay);
this._onPoll = this._onPoll.bind(this);
this.update('Looking for planes...', false, true);
if (scene) {
scene.add(this.planes);
}
}
/**
* Enables displaying and pulling getFrameData
*/
enable() {
if (this._timer) {
this.disable();
}
this._timer = setInterval(this._onPoll, PLANES_POLLING_TIMER);
this.planes.enable();
}
/**
* Disables displaying and pulling getFrameData
*/
disable() {
clearInterval(this._timer);
this._timer = null;
this.planes.disable();
}
/**
* @param {number} count
* @return {string}
*/
_planesToString(count) {
return `${count} plane${count === 1 ? '' : 's'} found`;
}
/**
* Polling callback while enabled, used to fetch and orchestrate
* plane rendering.
*/
_onPoll() {
const planeCount = this.planes.size();
// Plane count will change much less often than position or hits;
// don't even bother throttling to rerender the same information
// if there are no changes
if (this._lastPlaneCount !== planeCount) {
this.update(this._planesToString(planeCount), planeCount > 0, true);
}
this._lastPlaneCount = planeCount;
}
}
export default ARDebug;