diff --git a/examples/src/examples/xr/ar-anchors-persistence.mjs b/examples/src/examples/xr/ar-anchors-persistence.mjs new file mode 100644 index 00000000000..d6a6e524697 --- /dev/null +++ b/examples/src/examples/xr/ar-anchors-persistence.mjs @@ -0,0 +1,290 @@ +import * as pc from 'playcanvas'; + +/** + * @typedef {import('../../options.mjs').ExampleOptions} ExampleOptions + * @param {import('../../options.mjs').ExampleOptions} options - The example options. + * @returns {Promise} The example application. + */ +async function example({ canvas }) { + /** + * @param {string} msg - The message. + */ + const message = function (msg) { + /** @type {HTMLDivElement} */ + let el = document.querySelector('.message'); + if (!el) { + el = document.createElement('div'); + el.classList.add('message'); + el.style.position = 'absolute'; + el.style.bottom = '96px'; + el.style.right = '0'; + el.style.padding = '8px 16px'; + el.style.fontFamily = 'Helvetica, Arial, sans-serif'; + el.style.color = '#fff'; + el.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; + document.body.append(el); + } + el.textContent = msg; + }; + + const app = new pc.Application(canvas, { + mouse: new pc.Mouse(canvas), + touch: new pc.TouchDevice(canvas), + keyboard: new pc.Keyboard(window), + graphicsDeviceOptions: { alpha: true } + }); + app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); + app.setCanvasResolution(pc.RESOLUTION_AUTO); + + // Ensure canvas is resized when window changes size + const resize = () => app.resizeCanvas(); + window.addEventListener('resize', resize); + app.on('destroy', () => { + window.removeEventListener('resize', resize); + }); + + // use device pixel ratio + app.graphicsDevice.maxPixelRatio = window.devicePixelRatio; + + app.start(); + + // create camera + const camera = new pc.Entity(); + camera.addComponent('camera', { + clearColor: new pc.Color(0, 0, 0, 0), + farClip: 10000 + }); + app.root.addChild(camera); + + const l = new pc.Entity(); + l.addComponent("light", { + type: "spot", + range: 30 + }); + l.translate(0, 10, 0); + app.root.addChild(l); + + const cone = new pc.Entity(); + cone.addComponent('render', { + type: 'cone' + }); + cone.setLocalScale(0.1, 0.1, 0.1); + + const materialStandard = new pc.StandardMaterial(); + + const materialPersistent = new pc.StandardMaterial(); + materialPersistent.diffuse = new pc.Color(0.5, 1, 0.5); + + const createAnchor = (hitTestResult) => { + app.xr.anchors.create(hitTestResult, (err, anchor) => { + if (err) return message("Failed creating Anchor"); + if (!anchor) return message("Anchor has not been created"); + + anchor.persist((err, uuid) => { + if (err) { + message('Anchor failed to persist'); + } + }); + }); + }; + + if (app.xr.supported) { + const activate = function () { + if (app.xr.isAvailable(pc.XRTYPE_AR)) { + camera.camera.startXr(pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, { + anchors: true, + callback: function (err) { + if (err) message("WebXR Immersive AR failed to start: " + err.message); + } + }); + } else { + message("Immersive AR is not available"); + } + }; + + app.mouse.on("mousedown", function () { + if (!app.xr.active) + activate(); + }); + + if (app.touch) { + app.touch.on("touchend", function (evt) { + if (!app.xr.active) { + // if not in VR, activate + activate(); + } else { + // otherwise reset camera + camera.camera.endXr(); + } + + evt.event.preventDefault(); + evt.event.stopPropagation(); + }); + } + + // end session by keyboard ESC + app.keyboard.on('keydown', function (evt) { + if (evt.key === pc.KEY_ESCAPE && app.xr.active) { + app.xr.end(); + } + }); + + app.xr.on('start', function () { + message("Immersive AR session has started"); + + // restore all persistent anchors + if (app.xr.anchors.persistence) { + const uuids = app.xr.anchors.uuids; + for(let i = 0; i < uuids.length; i++) { + app.xr.anchors.restore(uuids[i]); + } + } + }); + app.xr.on('end', function () { + message("Immersive AR session has ended"); + }); + app.xr.on('available:' + pc.XRTYPE_AR, function (available) { + if (available) { + if (!app.xr.hitTest.supported) { + message("AR Hit Test is not supported"); + } else if (!app.xr.anchors.supported) { + message("AR Anchors are not supported"); + } else if (!app.xr.anchors.persistence) { + message("AR Anchors Persistence is not supported"); + } else { + message("Touch screen to start AR session and look at the floor or walls"); + } + } else { + message("Immersive AR is unavailable"); + } + }); + + // create hit test sources for all input sources + if (app.xr.hitTest.supported && app.xr.anchors.supported) { + app.xr.input.on('add', (inputSource) => { + inputSource.hitTestStart({ + entityTypes: [pc.XRTRACKABLE_MESH], + callback: (err, hitTestSource) => { + if (err) return; + + let target = new pc.Entity(); + target.addComponent("render", { + type: "cylinder" + }); + target.setLocalScale(0.1, 0.01, 0.1); + app.root.addChild(target); + + let lastHitTestResult = null; + + // persistent input sources + if (inputSource.targetRayMode === pc.XRTARGETRAY_POINTER) { + inputSource.on('select', () => { + if (lastHitTestResult) + createAnchor(lastHitTestResult); + }); + } + + hitTestSource.on('result', (position, rotation, inputSource, hitTestResult) => { + target.setPosition(position); + target.setRotation(rotation); + lastHitTestResult = hitTestResult; + }); + + hitTestSource.once('remove', () => { + target.destroy(); + target = null; + + // mobile screen input source + if (inputSource.targetRayMode === pc.XRTARGETRAY_SCREEN && lastHitTestResult) + createAnchor(lastHitTestResult); + + lastHitTestResult = null; + }); + } + }); + }); + } + + if (app.xr.anchors.persistence) { + app.on('update', () => { + const inputSources = app.xr.input.inputSources; + for(let i = 0; i < inputSources.length; i++) { + const inputSource = inputSources[i]; + + if (!inputSource.gamepad) + continue; + + for(let b = 0; b < inputSource.gamepad.buttons.length; b++) { + if (!inputSource.gamepad.buttons[b].pressed) + continue; + + if (b === 0) continue; + + // clear all persistent anchors + const uuids = app.xr.anchors.uuids; + for(let a = 0; a < uuids.length; a++) { + app.xr.anchors.forget(uuids[a]); + } + return; + } + } + }); + } + + // create entity for anchors + app.xr.anchors.on('add', (anchor) => { + let entity = cone.clone(); + app.root.addChild(entity); + entity.setPosition(anchor.getPosition()); + entity.setRotation(anchor.getRotation()); + entity.translateLocal(0, 0.05, 0); + + anchor.on('change', () => { + entity.setPosition(anchor.getPosition()); + entity.setRotation(anchor.getRotation()); + entity.translateLocal(0, 0.05, 0); + }); + + if (anchor.persistent) { + entity.render.material = materialPersistent; + } + + anchor.on('persist', () => { + entity.render.material = materialPersistent; + }); + + anchor.on('forget', () => { + entity.render.material = materialStandard; + }); + + anchor.once('destroy', () => { + entity.destroy(); + entity = null; + }); + }); + + if (!app.xr.isAvailable(pc.XRTYPE_AR)) { + message("Immersive AR is not available"); + } else if (!app.xr.hitTest.supported) { + message("AR Hit Test is not supported"); + } else if (!app.xr.anchors.supported) { + message("AR Anchors are not supported"); + } else if (!app.xr.anchors.persistence) { + message("AR Anchors Persistence is not supported"); + } else { + message("Touch screen to start AR session and look at the floor or walls"); + } + } else { + message("WebXR is not supported"); + } + + return app; +} + +class ArAnchorsPersistentExample { + static CATEGORY = 'XR'; + static NAME = 'AR Anchors Persistence'; + static example = example; +} + +export { ArAnchorsPersistentExample }; diff --git a/examples/src/examples/xr/ar-hit-test-anchors.mjs b/examples/src/examples/xr/ar-hit-test-anchors.mjs new file mode 100644 index 00000000000..c6a266b2de5 --- /dev/null +++ b/examples/src/examples/xr/ar-hit-test-anchors.mjs @@ -0,0 +1,258 @@ +import * as pc from 'playcanvas'; + +/** + * @typedef {import('../../options.mjs').ExampleOptions} ExampleOptions + * @param {import('../../options.mjs').ExampleOptions} options - The example options. + * @returns {Promise} The example application. + */ +async function example({ canvas }) { + /** + * @param {string} msg - The message. + */ + const message = function (msg) { + /** @type {HTMLDivElement} */ + let el = document.querySelector('.message'); + if (!el) { + el = document.createElement('div'); + el.classList.add('message'); + document.body.append(el); + } + el.textContent = msg; + }; + + const app = new pc.Application(canvas, { + mouse: new pc.Mouse(canvas), + touch: new pc.TouchDevice(canvas), + keyboard: new pc.Keyboard(window), + graphicsDeviceOptions: { alpha: true } + }); + app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); + app.setCanvasResolution(pc.RESOLUTION_AUTO); + + // Ensure canvas is resized when window changes size + const resize = () => app.resizeCanvas(); + window.addEventListener('resize', resize); + app.on('destroy', () => { + window.removeEventListener('resize', resize); + }); + + // use device pixel ratio + app.graphicsDevice.maxPixelRatio = window.devicePixelRatio; + + app.start(); + + // create camera + const c = new pc.Entity(); + c.addComponent('camera', { + clearColor: new pc.Color(0, 0, 0, 0), + farClip: 10000 + }); + app.root.addChild(c); + + const l = new pc.Entity(); + l.addComponent("light", { + type: "spot", + range: 30 + }); + l.translate(0, 10, 0); + app.root.addChild(l); + + const target = new pc.Entity(); + target.addComponent('render', { + type: "cylinder" + }); + target.setLocalScale(0.1, 0.01, 0.1); + app.root.addChild(target); + + const cone = new pc.Entity(); + cone.addComponent('render', { + type: 'cone' + }); + cone.setLocalScale(0.1, 0.1, 0.1); + + const createAnchor = (hitTestResult) => { + app.xr.anchors.create(hitTestResult, (err, anchor) => { + if (err) return message("Failed creating Anchor"); + if (!anchor) return message("Anchor has not been created"); + + let entity = cone.clone(); + app.root.addChild(entity); + entity.setPosition(anchor.getPosition()); + entity.setRotation(anchor.getRotation()); + entity.translateLocal(0, 0.05, 0); + + anchor.on('change', () => { + entity.setPosition(anchor.getPosition()); + entity.setRotation(anchor.getRotation()); + entity.translateLocal(0, 0.05, 0); + }); + + anchor.once('destroy', () => { + entity.destroy(); + entity = null; + }); + }); + }; + + if (app.xr.supported) { + const activate = function () { + if (app.xr.isAvailable(pc.XRTYPE_AR)) { + c.camera.startXr(pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, { + anchors: true, + callback: function (err) { + if (err) message("WebXR Immersive AR failed to start: " + err.message); + } + }); + } else { + message("Immersive AR is not available"); + } + }; + + app.mouse.on("mousedown", function () { + if (!app.xr.active) + activate(); + }); + + if (app.touch) { + app.touch.on("touchend", function (evt) { + if (!app.xr.active) { + // if not in VR, activate + activate(); + } else { + // otherwise reset camera + c.camera.endXr(); + } + + evt.event.preventDefault(); + evt.event.stopPropagation(); + }); + } + + // end session by keyboard ESC + app.keyboard.on('keydown', function (evt) { + if (evt.key === pc.KEY_ESCAPE && app.xr.active) { + app.xr.end(); + } + }); + + app.xr.on('start', function () { + message("Immersive AR session has started"); + + if (!app.xr.hitTest.supported || !app.xr.anchors.supported) + return; + + // provide gaze-like way to create anchors + // best for mobile phones + let lastHitTestResult = null; + + app.xr.hitTest.start({ + entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE, pc.XRTRACKABLE_MESH], + callback: function (err, hitTestSource) { + if (err) { + message("Failed to start AR hit test"); + return; + } + + hitTestSource.on('result', function (position, rotation, inputSource, hitTestResult) { + target.setPosition(position); + target.setRotation(rotation); + lastHitTestResult = hitTestResult; + }); + } + }); + + app.xr.input.on('select', (inputSource) => { + if (inputSource.targetRayMode !== pc.XRTARGETRAY_SCREEN) + return; + + if (!lastHitTestResult) + return; + + createAnchor(lastHitTestResult); + }); + }); + app.xr.on('end', function () { + message("Immersive AR session has ended"); + }); + app.xr.on('available:' + pc.XRTYPE_AR, function (available) { + if (available) { + if (!app.xr.hitTest.supported) { + message("AR Hit Test is not supported"); + } else if (!app.xr.anchors.supported) { + message("AR Anchors are not supported"); + } else { + message("Touch screen to start AR session and look at the floor or walls"); + } + } else { + message("Immersive AR is unavailable"); + } + }); + + // create hit test sources for all input sources + if (app.xr.hitTest.supported && app.xr.anchors.supported) { + app.xr.input.on('add', (inputSource) => { + inputSource.hitTestStart({ + entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE], + callback: (err, hitTestSource) => { + if (err) return; + + let target = new pc.Entity(); + target.addComponent("render", { + type: "cylinder" + }); + target.setLocalScale(0.1, 0.01, 0.1); + app.root.addChild(target); + + let lastHitTestResult = null; + + // persistent input sources + if (inputSource.targetRayMode === pc.XRTARGETRAY_POINTER) { + inputSource.on('select', () => { + if (lastHitTestResult) + createAnchor(lastHitTestResult); + }); + } + + hitTestSource.on('result', (position, rotation, inputSource, hitTestResult) => { + target.setPosition(position); + target.setRotation(rotation); + lastHitTestResult = hitTestResult; + }); + + hitTestSource.once('remove', () => { + target.destroy(); + target = null; + + // mobile screen input source + if (inputSource.targetRayMode === pc.XRTARGETRAY_SCREEN && lastHitTestResult) + createAnchor(lastHitTestResult); + + lastHitTestResult = null; + }); + } + }); + }); + } + + if (!app.xr.isAvailable(pc.XRTYPE_AR)) { + message("Immersive AR is not available"); + } else if (!app.xr.hitTest.supported) { + message("AR Hit Test is not supported"); + } else if (!app.xr.anchors.supported) { + message("AR Anchors are not supported"); + } else { + message("Touch screen to start AR session and look at the floor or walls"); + } + } else { + message("WebXR is not supported"); + } + return app; +} + +class ArHitTestAnchorsExample { + static CATEGORY = 'XR'; + static NAME = 'AR Hit Test Anchors'; + static example = example; +} + +export { ArHitTestAnchorsExample }; diff --git a/examples/src/examples/xr/index.mjs b/examples/src/examples/xr/index.mjs index 0580abfeded..dc6f77415bd 100644 --- a/examples/src/examples/xr/index.mjs +++ b/examples/src/examples/xr/index.mjs @@ -1,5 +1,7 @@ export * from "./ar-basic.mjs"; export * from "./ar-hit-test.mjs"; +export * from "./ar-hit-test-anchors.mjs"; +export * from "./ar-anchors-persistence.mjs"; export * from "./ar-plane-detection.mjs"; export * from "./vr-basic.mjs"; export * from './vr-controllers.mjs'; diff --git a/src/framework/xr/xr-anchor.js b/src/framework/xr/xr-anchor.js index 3ed6cdf9a1f..fc1c109b9ee 100644 --- a/src/framework/xr/xr-anchor.js +++ b/src/framework/xr/xr-anchor.js @@ -3,6 +3,22 @@ import { EventHandler } from '../../core/event-handler.js'; import { Vec3 } from '../../core/math/vec3.js'; import { Quat } from '../../core/math/quat.js'; +/** + * Callback used by {@link XrAnchor#persist}. + * + * @callback XrAnchorPersistCallback + * @param {Error|null} err - The Error object if failed to persist an anchor or null. + * @param {string|null} uuid - unique string that can be used to restore {@link XRAnchor} + * in another session. + */ + +/** + * Callback used by {@link XrAnchor#forget}. + * + * @callback XrAnchorForgetCallback + * @param {Error|null} err - The Error object if failed to forget an anchor or null if succeeded. + */ + /** * An anchor keeps track of a position and rotation that is fixed relative to the real world. * This allows the application to adjust the location of the virtual objects placed in the @@ -25,16 +41,30 @@ class XrAnchor extends EventHandler { */ _rotation = new Quat(); + /** + * @type {string|null} + * @private + */ + _uuid = null; + + /** + * @type {string[]|null} + * @private + */ + _uuidRequests = null; + /** * @param {import('./xr-anchors.js').XrAnchors} anchors - Anchor manager. * @param {object} xrAnchor - native XRAnchor object that is provided by WebXR API + * @param {string|null} uuid - ID string associated with a persistent anchor * @hideconstructor */ - constructor(anchors, xrAnchor) { + constructor(anchors, xrAnchor, uuid = null) { super(); this._anchors = anchors; this._xrAnchor = xrAnchor; + this._uuid = uuid; } /** @@ -61,21 +91,36 @@ class XrAnchor extends EventHandler { * }); */ + /** + * Fired when an {@link XrAnchor}'s has been persisted. + * + * @event XrAnchor#persist + * @param {string} uuid - Unique string that can be used to restore this anchor. + * @example + * anchor.on('persist', function (uuid) { + * // anchor has been persisted + * }); + */ + + /** + * Fired when an {@link XrAnchor}'s has been forgotten. + * + * @event XrAnchor#forget + * @example + * anchor.on('forget', function () { + * // anchor has been forgotten + * }); + */ + /** * Destroy an anchor. */ destroy() { if (!this._xrAnchor) return; - this._anchors._index.delete(this._xrAnchor); - - const ind = this._anchors._list.indexOf(this); - if (ind !== -1) this._anchors._list.splice(ind, 1); - + const xrAnchor = this._xrAnchor; this._xrAnchor.delete(); this._xrAnchor = null; - - this.fire('destroy'); - this._anchors.fire('destroy', this); + this.fire('destroy', xrAnchor, this); } /** @@ -114,6 +159,90 @@ class XrAnchor extends EventHandler { getRotation() { return this._rotation; } + + /** + * This method provides a way to persist anchor and get a string with UUID. + * UUID can be used later to restore anchor. + * Bear in mind that underlying systems might have a limit on number of anchors + * allowed to be persisted. + * + * @param {XrAnchorPersistCallback} [callback] - Callback to fire when anchor + * persistent UUID has been generated or error if failed. + */ + persist(callback) { + if (!this._anchors.persistence) { + callback?.(new Error('Persistent Anchors are not supported'), null); + return; + } + + if (this._uuid) { + callback?.(null, this._uuid); + return; + } + + if (this._uuidRequests) { + if (callback) this._uuidRequests.push(callback); + return; + } + + this._uuidRequests = []; + + this._xrAnchor.requestPersistentHandle() + .then((uuid) => { + this._uuid = uuid; + this._anchors._indexByUuid.set(this._uuid, this); + callback?.(null, uuid); + for (let i = 0; i < this._uuidRequests.length; i++) { + this._uuidRequests[i](null, uuid); + } + this._uuidRequests = null; + this.fire('persist', uuid); + }) + .catch((ex) => { + callback?.(ex, null); + for (let i = 0; i < this._uuidRequests.length; i++) { + this._uuidRequests[i](ex); + } + this._uuidRequests = null; + }); + } + + /** + * This method provides a way to remove persistent UUID of an anchor for underlying systems. + * + * @param {XrAnchorForgetCallback} [callback] - Callback to fire when anchor has been + * forgotten or error if failed. + */ + forget(callback) { + if (!this._uuid) { + callback?.(new Error('Anchor is not persistent')); + return; + } + + this._anchors.forget(this._uuid, (ex) => { + this._uuid = null; + callback?.(ex); + this.fire('forget'); + }); + } + + /** + * UUID string of a persistent anchor or null if not presisted. + * + * @type {null|string} + */ + get uuid() { + return this._uuid; + } + + /** + * True if an anchor is persistent. + * + * @type {boolean} + */ + get persistent() { + return !!this._uuid; + } } export { XrAnchor }; diff --git a/src/framework/xr/xr-anchors.js b/src/framework/xr/xr-anchors.js index c273878a12d..2b950392f7b 100644 --- a/src/framework/xr/xr-anchors.js +++ b/src/framework/xr/xr-anchors.js @@ -32,10 +32,16 @@ class XrAnchors extends EventHandler { */ _supported = platform.browser && !!window.XRAnchor; + /** + * @type {boolean} + * @private + */ + _persistence = platform.browser && !!window?.XRSession?.prototype.restorePersistentAnchor; + /** * List of anchor creation requests. * - * @type {Array} + * @type {object[]} * @private */ _creationQueue = []; @@ -44,13 +50,21 @@ class XrAnchors extends EventHandler { * Index of XrAnchors, with XRAnchor (native handle) used as a key. * * @type {Map} - * @ignore + * @private */ _index = new Map(); /** - * @type {Array} - * @ignore + * Index of XrAnchors, with UUID (persistent string) used as a key. + * + * @type {Map} + * @private + */ + _indexByUuid = new Map(); + + /** + * @type {XrAnchor[]} + * @private */ _list = []; @@ -117,34 +131,176 @@ class XrAnchors extends EventHandler { } this._creationQueue.length = 0; + this._index.clear(); + this._indexByUuid.clear(); + // destroy all anchors - if (this._list) { - let i = this._list.length; - while (i--) { - this._list[i].destroy(); - } - this._list.length = 0; + let i = this._list.length; + while (i--) { + this._list[i].destroy(); } + this._list.length = 0; + } + + /** + * @param {XRAnchor} xrAnchor - XRAnchor that has been added. + * @param {string|null} [uuid] - UUID string associated with persistent anchor. + * @returns {XrAnchor} new instance of XrAnchor. + * @private + */ + _createAnchor(xrAnchor, uuid = null) { + const anchor = new XrAnchor(this, xrAnchor, uuid); + this._index.set(xrAnchor, anchor); + if (uuid) this._indexByUuid.set(uuid, anchor); + this._list.push(anchor); + anchor.once('destroy', this._onAnchorDestroy, this); + return anchor; + } + + /** + * @param {XRAnchor} xrAnchor - XRAnchor that has been destroyed. + * @param {XrAnchor} anchor - Anchor that has been destroyed. + * @private + */ + _onAnchorDestroy(xrAnchor, anchor) { + this._index.delete(xrAnchor); + if (anchor.uuid) this._indexByUuid.delete(anchor.uuid); + const ind = this._list.indexOf(anchor); + if (ind !== -1) this._list.splice(ind, 1); + this.fire('destroy', anchor); } /** * Create anchor with position, rotation and a callback. * - * @param {import('../../core/math/vec3.js').Vec3} position - Position for an anchor. - * @param {import('../../core/math/quat.js').Quat} [rotation] - Rotation for an anchor. + * @param {import('../../core/math/vec3.js').Vec3|XRHitTestResult} position - Position for an anchor. + * @param {import('../../core/math/quat.js').Quat|XrAnchorCreate} [rotation] - Rotation for an anchor. * @param {XrAnchorCreate} [callback] - Callback to fire when anchor was created or failed to be created. * @example + * // create an anchor using a position and rotation * app.xr.anchors.create(position, rotation, function (err, anchor) { * if (!err) { * // new anchor has been created * } * }); + * @example + * // create an anchor from a hit test result + * hitTestSource.on('result', (position, rotation, inputSource, hitTestResult) => { + * app.xr.anchors.create(hitTestResult, function (err, anchor) { + * if (!err) { + * // new anchor has been created + * } + * }); + * }); */ create(position, rotation, callback) { - this._creationQueue.push({ - transform: new XRRigidTransform(position, rotation), // eslint-disable-line no-undef - callback: callback - }); + // eslint-disable-next-line no-undef + if (window.XRHitTestResult && position instanceof XRHitTestResult) { + const hitResult = position; + callback = rotation; + + if (!this._supported) { + callback?.(new Error('Anchors API is not supported'), null); + return; + } + + if (!hitResult.createAnchor) { + callback?.(new Error('Creating Anchor from Hit Test is not supported'), null); + return; + } + + hitResult.createAnchor() + .then((xrAnchor) => { + const anchor = this._createAnchor(xrAnchor); + callback?.(null, anchor); + this.fire('add', anchor); + }) + .catch((ex) => { + callback?.(ex, null); + this.fire('error', ex); + }); + } else { + this._creationQueue.push({ + transform: new XRRigidTransform(position, rotation), // eslint-disable-line no-undef + callback: callback + }); + } + } + + /** + * Restore anchor using persistent UUID. + * + * @param {string} uuid - UUID string associated with persistent anchor. + * @param {XrAnchorCreate} [callback] - Callback to fire when anchor was created or failed to be created. + * @example + * // restore an anchor using uuid string + * app.xr.anchors.restore(uuid, function (err, anchor) { + * if (!err) { + * // new anchor has been created + * } + * }); + * @example + * // restore all available persistent anchors + * const uuids = app.xr.anchors.uuids; + * for(let i = 0; i < uuids.length; i++) { + * app.xr.anchors.restore(uuids[i]); + * } + */ + restore(uuid, callback) { + if (!this._persistence) { + callback?.(new Error('Anchor Persistence is not supported'), null); + return; + } + + if (!this.manager.active) { + callback?.(new Error('WebXR session is not active'), null); + return; + } + + this.manager.session.restorePersistentAnchor(uuid) + .then((xrAnchor) => { + const anchor = this._createAnchor(xrAnchor, uuid); + callback?.(null, anchor); + this.fire('add', anchor); + }) + .catch((ex) => { + callback?.(ex, null); + this.fire('error', ex); + }); + } + + /** + * Forget an anchor by removing its UUID from underlying systems. + * + * @param {string} uuid - UUID string associated with persistent anchor. + * @param {import('./xr-anchor.js').XrAnchorForgetCallback} [callback] - Callback to + * fire when anchor persistent data was removed or error if failed. + * @example + * // forget all available anchors + * const uuids = app.xr.anchors.uuids; + * for(let i = 0; i < uuids.length; i++) { + * app.xr.anchors.forget(uuids[i]); + * } + */ + forget(uuid, callback) { + if (!this._persistence) { + callback?.(new Error('Anchor Persistence is not supported')); + return; + } + + if (!this.manager.active) { + callback?.(new Error('WebXR session is not active')); + return; + } + + this.manager.session.deletePersistentAnchor(uuid) + .then(() => { + callback?.(null); + }) + .catch((ex) => { + callback?.(ex); + this.fire('error', ex); + }); } /** @@ -178,6 +334,7 @@ class XrAnchors extends EventHandler { if (frame.trackedAnchors.has(xrAnchor)) continue; + this._index.delete(xrAnchor); anchor.destroy(); } @@ -199,9 +356,7 @@ class XrAnchors extends EventHandler { continue; } - const anchor = new XrAnchor(this, xrAnchor); - this._index.set(xrAnchor, anchor); - this._list.push(anchor); + const anchor = this._createAnchor(xrAnchor); anchor.update(frame); const callback = this._callbacksAnchors.get(xrAnchor); @@ -223,10 +378,34 @@ class XrAnchors extends EventHandler { return this._supported; } + /** + * True if Anchors support persistence. + * + * @type {boolean} + */ + get persistence() { + return this._persistence; + } + + /** + * Array of UUID strings of persistent anchors, or null if not available. + * + * @type {null|string[]} + */ + get uuids() { + if (!this._persistence) + return null; + + if (!this.manager.active) + return null; + + return this.manager.session.persistentAnchors; + } + /** * List of available {@link XrAnchor}s. * - * @type {Array} + * @type {XrAnchor[]} */ get list() { return this._list; diff --git a/src/framework/xr/xr-hit-test-source.js b/src/framework/xr/xr-hit-test-source.js index d5a082cf486..3a619e9dc2c 100644 --- a/src/framework/xr/xr-hit-test-source.js +++ b/src/framework/xr/xr-hit-test-source.js @@ -75,8 +75,9 @@ class XrHitTestSource extends EventHandler { * @param {Quat} rotation - Rotation of hit test. * @param {import('./xr-input-source.js').XrInputSource|null} inputSource - If is transient hit * test source, then it will provide related input source. + * @param {XRHitTestResult} XRHitTestResult - object that is created by WebXR API. * @example - * hitTestSource.on('result', function (position, rotation, inputSource) { + * hitTestSource.on('result', function (position, rotation) { * target.setPosition(position); * target.setRotation(rotation); * }); @@ -144,6 +145,7 @@ class XrHitTestSource extends EventHandler { } let candidateDistance = Infinity; + let candidateHitTestResult = null; const position = poolVec3.pop() ?? new Vec3(); const rotation = poolQuat.pop() ?? new Quat(); @@ -156,12 +158,13 @@ class XrHitTestSource extends EventHandler { continue; candidateDistance = distance; + candidateHitTestResult = results[i]; position.copy(pose.transform.position); rotation.copy(pose.transform.orientation); } - this.fire('result', position, rotation, inputSource); - this.manager.hitTest.fire('result', this, position, rotation, inputSource); + this.fire('result', position, rotation, inputSource, candidateHitTestResult); + this.manager.hitTest.fire('result', this, position, rotation, inputSource, candidateHitTestResult); poolVec3.push(origin); poolVec3.push(position); diff --git a/src/framework/xr/xr-manager.js b/src/framework/xr/xr-manager.js index 809710301bd..808c4b52dc1 100644 --- a/src/framework/xr/xr-manager.js +++ b/src/framework/xr/xr-manager.js @@ -142,6 +142,13 @@ class XrManager extends EventHandler { */ lightEstimation; + /** + * Provides access to Anchors. + * + * @type {XrAnchors} + */ + anchors; + /** * @type {import('../components/camera/component.js').CameraComponent} * @private