/** * A-Frame plugin for encantar.js * @author Alexandre Martins (https://github.com/alemart/encantar-js) * @license LGPL-3.0-or-later */ (function() { /* Usage of the indicated versions is encouraged */ __THIS_PLUGIN_HAS_BEEN_TESTED_WITH__({ 'encantar.js': { version: '0.4.0' }, 'A-Frame': { version: '1.4.2' } }); /** * AR Base System * @mixin ARBaseSystem */ const ARBaseSystem = () => ({ /** * AR Session * @type {Session | null} */ session: null, /** * Current frame: an object holding data to augment the physical scene. * If the AR scene is not initialized, this will be null. * @type {Frame | null} */ frame: null, /** * AR Viewer * @type {Viewer | null} */ viewer: null, /** * Pointer-based input (current frame) * Make sure to add a PointerTracker to your session in order to use these * @type {TrackablePointer[]} */ pointers: [], /** * AR Utilities * @type {object} */ utils: { /** * Convert an AR Vector2 to a THREE Vector2 * @param {Vector2} v * @returns {THREE.Vector2} */ convertVector2(v) { return new THREE.Vector2(v.x, v.y); }, /** * Convert an AR Vector3 to a THREE Vector3 * @param {Vector3} v * @returns {THREE.Vector3} */ convertVector3(v) { return new THREE.Vector3(v.x, v.y, v.z); }, /** * Convert an AR Quaternion to a THREE Quaternion * @param {Quaternion} q * @returns {THREE.Quaternion} */ convertQuaternion(q) { return new THREE.Quaternion(q.x, q.y, q.z, q.w); }, /** * Convert an AR Ray to a THREE Ray * @param {Ray} r * @returns {THREE.Ray} */ convertRay(r) { const origin = this.convertVector3(r.origin); const direction = this.convertVector3(r.direction); return new THREE.Ray(origin, direction); }, } }); /** * Internal Utilities */ const Utils = () => ({ findTrackedImage(frame, name = '') { if(frame === null) return null; for(const result of frame.results) { if(result.tracker.type == 'image-tracker') { for(const trackable of result.trackables) { if(name === '' || name === trackable.referenceImage.name) { return { projectionMatrix: result.viewer.view.projectionMatrix, viewMatrixInverse: result.viewer.pose.transform.matrix, modelMatrix: trackable.pose.transform.matrix, }; } } } } return null; }, findViewer(frame) { if(frame === null) return null; for(const result of frame.results) { if(result.tracker.type == 'image-tracker') { if(result.trackables.length > 0) return result.viewer; } } return null; }, findTrackablePointers(frame) { if(frame === null) return []; for(const result of frame.results) { if(result.tracker.type == 'pointer-tracker') return result.trackables; } return []; }, }); /* ========================================================================= */ /** * AR System * @name ARSystem * @type {object} * @mixes ARBaseSystem */ AFRAME.registerSystem('ar', Object.assign(ARBaseSystem(), { // el; // data; // schema; _utils: Utils(), _started: false, _components: [], _roots: [], init() { const scene = this.el; // validate if(!scene.getAttribute('ar-session')) { scene.setAttribute('ar-session', {}); // use a default ar-session //throw new Error('Missing ar-session in a-scene'); // other errors will appear } // initial setup scene.setAttribute('vr-mode-ui', { enabled: false }); scene.setAttribute('embedded', true); scene.setAttribute('renderer', { alpha: true }); // pause the scene until we're ready scene.addEventListener('ar-started', () => { scene.play(); }); scene.addEventListener('loaded', () => { //scene.pause(); Promise.resolve().then(() => scene.pause()); }); /* // we take control of the rendering scene.addEventListener('loaded', () => { scene.renderer.setAnimationLoop(null); }); */ }, tick() { const scene = this.el; // we take control of the rendering scene.renderer.setAnimationLoop(null); // manually update the roots for(let i = 0; i < this._roots.length; i++) this._roots[i].teek(); }, startSession() { if(this._started) throw new Error('Can\'t start an AR session twice'); this._started = true; for(const component of this._components) component.validate(); return Speedy.Promise.all([ this._loadSources(), this._loadTrackers(), this._loadViewport(), this._loadPreferences(), ]) .then(([ sources, trackers, viewport, preferences, ]) => AR.startSession( Object.assign({}, preferences, { sources, trackers, viewport }) )) .then(session => { // setup this.session = session; this._configureSession(); this._startAnimationLoop(); // we're done! const scene = this.el; scene.emit('ar-started', { ar: this }); return session; }) .catch(error => { console.error(error); throw error; }); }, register(component) { this._register(this._components, component); }, unregister(component) { this._unregister(this._components, component); }, registerRoot(component) { this._register(this._roots, component); }, unregisterRoot(component) { this._unregister(this._roots, component); }, _register(arr, component) { const j = arr.indexOf(component); if(j < 0) arr.push(component); }, _unregister(arr, component) { const j = arr.indexOf(component); if(j >= 0) arr.splice(j, 1); }, _configureSession() { const scene = this.el; const session = this.session; if(session.viewport.canvas !== scene.canvas) { session.end(); throw new Error('Invalid A-Frame canvas'); } session.addEventListener('end', () => { this.viewer = null; this.frame = null; this.pointers.length = 0; }); session.viewport.addEventListener('resize', () => { // timeout : internals of aframe (a-scene.js) setTimeout(() => this._resize(), 200); this._resize(); }); this._resize(); // initial setup }, _startAnimationLoop() { const scene = this.el; const session = this.session; scene.object3D.background = null; // animation loop const animate = (time, frame) => { this.frame = frame; this.viewer = this._utils.findViewer(frame); this._updateTrackablePointers(); scene.render(); session.requestAnimationFrame(animate); }; session.requestAnimationFrame(animate); }, _resize() { const scene = this.el; const size = this.session.viewport.virtualSize; scene.renderer.setPixelRatio(1.0); scene.renderer.setSize(size.width, size.height, false); }, _loadTrackers() { const scene = this.el; const groups = Array.from( scene.querySelectorAll('[ar-trackers]'), el => el.components['ar-trackers'] ); if(groups.length > 1) throw new Error('Can\'t define multiple groups of ar-trackers'); else if(groups.length == 0) throw new Error('Missing ar-trackers'); return groups[0].trackers(); }, _loadSources() { const scene = this.el; const groups = Array.from( scene.querySelectorAll('[ar-sources]'), el => el.components['ar-sources'] ); if(groups.length > 1) throw new Error('Can\'t define multiple groups of ar-sources'); else if(groups.length == 0) throw new Error('Missing ar-sources'); return groups[0].sources(); }, _loadViewport() { const scene = this.el; const viewports = Array.from( scene.querySelectorAll('[ar-viewport]'), el => el.components['ar-viewport'] ); if(viewports.length > 1) throw new Error('Can\'t define multiple ar-viewport\'s'); else if(viewports.length == 0) throw new Error('Missing ar-viewport'); return viewports[0].viewport(); }, _loadPreferences() { const scene = this.el; const sessionComponent = scene.components['ar-session']; if(sessionComponent === undefined) throw new Error('Missing ar-session in a-scene'); return sessionComponent.preferences(); }, _updateTrackablePointers() { this.pointers.length = 0; const newPointers = this._utils.findTrackablePointers(this.frame); if(newPointers.length > 0) this.pointers.push.apply(this.pointers, newPointers); }, })); /** * AR Component * @mixin ARComponent */ const ARComponent = obj => Object.assign({}, obj, { // el; // data; // id; init() { Object.defineProperty(this, 'ar', { get: function() { return this.el.sceneEl.systems.ar; } }); this.ar.register(this); if(obj.init !== undefined) obj.init.call(this); if(this.el.sceneEl.hasLoaded) this.validate(); }, remove() { if(obj.remove !== undefined) obj.remove.call(this); this.ar.unregister(this); }, validate() { if(obj.validate !== undefined) obj.validate.call(this); } }); /** * AR Session */ AFRAME.registerComponent('ar-session', ARComponent({ schema: { /** session mode: "immersive" | "inline" */ 'mode': { type: 'string', default: 'immersive' }, /** show stats panel? */ 'stats': { type: 'boolean', default: false }, /** show gizmos? */ 'gizmos': { type: 'boolean', default: false }, /** start the session automatically? */ 'autoplay': { type: 'boolean', default: true }, }, sceneOnly: true, _started: false, init() { this._started = false; }, play() { // start the session (run once) if(!this._started) { this._started = true; if(this.data.autoplay) this.startSession(); } }, remove() { // end the session if(this.ar.session !== null) this.ar.session.end(); }, preferences() { return { mode: this.data.mode, stats: this.data.stats, gizmos: this.data.gizmos }; }, startSession() { return this.ar.startSession(); }, })); /* ========================================================================= */ /** * AR Camera */ AFRAME.registerComponent('ar-camera', ARComponent({ dependencies: ['camera'], init() { const el = this.el; el.setAttribute('camera', { active: true }); el.setAttribute('wasd-controls', { enabled: false }); el.setAttribute('look-controls', { enabled: false }); el.setAttribute('position', { x: 0, y: 0, z: 0 }); // A-Frame sets y = 1.6m for VR const camera = el.getObject3D('camera'); camera.matrixAutoUpdate = false; }, tick() { const ar = this.ar; const el = this.el; const tracked = ar._utils.findTrackedImage(ar.frame); if(tracked === null) return; const camera = el.getObject3D('camera'); camera.projectionMatrix.fromArray(tracked.projectionMatrix.read()); camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert(); camera.matrix.fromArray(tracked.viewMatrixInverse.read()); camera.updateMatrixWorld(true); //console.log('projectionMatrix', tracked.projectionMatrix.read()); //console.log('viewMatrixInverse', tracked.viewMatrixInverse.read()); }, validate() { if(!this.el.getAttribute('camera')) throw new Error('Incorrect ar-camera'); if(this.el.parentNode !== this.el.sceneEl) throw new Error('ar-camera must be a direct child of a-scene'); }, })); AFRAME.registerPrimitive('ar-camera', { defaultComponents: { 'ar-camera': {}, 'camera': {} } }); /** * AR Root node */ AFRAME.registerComponent('ar-root', ARComponent({ schema: { /** the name of a reference image (target) or the empty string (to match any target) */ 'referenceImage': { type: 'string', default: '' }, }, _origin: null, _firstRun: true, init() { const origin = new THREE.Group(); origin.matrixAutoUpdate = false; const root = this.el.object3D; root.parent.add(origin); origin.add(root); this._origin = origin; this._firstRun = true; this.ar.registerRoot(this); }, remove() { const origin = this._origin; const root = this.el.object3D; origin.parent.add(root); origin.removeFromParent(); this._origin = null; this.ar.unregisterRoot(this); }, play() { const origin = this._origin; origin.visible = true; if(this._firstRun) { this._firstRun = false; origin.visible = false; this.el.pause(); } }, pause() { const origin = this._origin; origin.visible = false; }, teek() { const ar = this.ar; const targetName = this.data.referenceImage; const tracked = ar._utils.findTrackedImage(ar.frame, targetName); if(tracked === null) { this.el.pause(); return; } const origin = this._origin; origin.matrix.fromArray(tracked.modelMatrix.read()); origin.updateMatrixWorld(true); this.el.play(); //console.log('modelMatrix', tracked.modelMatrix.toString()); }, validate() { if(this.el.parentNode !== this.el.sceneEl) throw new Error('ar-root must be a direct child of a-scene'); }, })); AFRAME.registerPrimitive('ar-root', { defaultComponents: { 'ar-root': {} }, mappings: { 'reference-image': 'ar-root.referenceImage' } }); /* ========================================================================= */ /** * AR Sources */ AFRAME.registerComponent('ar-sources', ARComponent({ validate() { if(this.el.parentNode !== this.el.sceneEl) throw new Error('ar-sources must be a direct child of a-scene'); }, sources() { const sources = []; for(const child of this.el.children) { if(child.components !== undefined) { for(const name in child.components) { const component = child.components[name]; if(component.ar === this.ar && typeof component.source == 'function') sources.push(component.source()); } } } return sources; }, })); AFRAME.registerPrimitive('ar-sources', { defaultComponents: { 'ar-sources': {} } }); /** * AR Camera Source */ AFRAME.registerComponent('ar-camera-source', ARComponent({ schema: { /** video resolution */ 'resolution': { type: 'string', default: 'md' }, /** facing mode: "environment" | "user" */ 'facingMode': { type: 'string', default: 'environment' }, }, validate() { if(!this.el.parentNode.getAttribute('ar-sources')) throw new Error('ar-camera-source must be a direct child of ar-sources'); }, source() { return AR.Source.Camera({ resolution: this.data.resolution, constraints: { facingMode: this.data.facingMode } }); }, })); AFRAME.registerPrimitive('ar-camera-source', { defaultComponents: { 'ar-camera-source': {} }, mappings: { 'resolution': 'ar-camera-source.resolution', 'facing-mode': 'ar-camera-source.facingMode', } }); /** * AR Video Source */ AFRAME.registerComponent('ar-video-source', ARComponent({ schema: { /** selector for a